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

Introduction

Cambrian is a language for stateful, message-based programs. A single .cam source describes entities, routes, and state transforms once; the Cambrian transpiler then lowers that source to one of several backends:

TargetCLI flagOutput
Solidity-EVM--target evm (default)Flat Solidity (^0.8.x) plus Foundry harness
Lean-EVM--target leanLean 4 + Lake project for formal verification (same EVM domain semantics)

One program can run on-chain (EVM) or as Lean proofs of the same EVM model — and the same language ideas apply when you are not targeting a blockchain at all.

Why Cambrian?

Many systems need explicit state, clear entry points, and disciplined communication between components. General-purpose languages leave those concerns scattered across imperative updates and ad-hoc APIs. Cambrian makes entity state, routes, and inter-entity messages part of the syntax.

There is no hidden control flow and no ambiguity about what an entity does when a route runs. Specs live in the same source: test, property / fuzz, and invariant blocks lower to Foundry tests or Lean theorems depending on the target.

Smart contracts are a common use case (durable state, multi-party messaging, expensive mistakes), but they are not the only one. The model fits anywhere you want member-centric state and message-shaped interfaces.

Key Principles

Member-Centric State

Members are named, typed fields that belong to an entity. Each member declares its own transformation rules inline, specifying exactly how it changes in response to each route. This inverts the traditional pattern where a function body scatters state updates across imperative statements.

m_balance: U256 {
    in deposit(amount) => m_balance + amount
    in withdraw(amount) => m_balance - amount
}

Routes

Routes are the named entry points of an entity. They declare parameters, optional preconditions, and the actions that run when the route is invoked.

routes {
    deposit(amount: U256) => []
    withdraw(amount: U256)
        where (m_balance >= amount) : throw InsufficientBalance()
    => [
        emit Withdrawal(msg::sender, amount)
    ]
    getBalance() -> U256 => [
        return(m_balance)
    ]
}

Temporal References

The temporal reference operator ^x refers to a member’s value after its transform for the current route has been applied. Use it when another member’s transform (or a later action) must see updated state, not the pre-transform snapshot.

m_count: u64 {
    in increment() => m_count + 1
}

m_total: u64 {
    in increment() => m_total + 1
}

m_over_limit: bool {
    // Without ^, m_count / m_total would still be the old values.
    in increment() => ^m_count > 100 || ^m_total > 1000
}

Pure Functions

Cambrian distinguishes state-modifying routes from pure functions. Pure functions cannot access or modify entity state; they operate solely on their inputs and produce a deterministic output.

pure fn max(a: u64, b: u64) -> u64 {
    if a > b { a } else { b }
}

Message-Based Routing

Inter-entity communication uses the send operator ~> to dispatch typed messages. On EVM this becomes a call (with optional value); on Lean it is a world-state transition.

Transfer(to, amount) ~> Token.address()

Properties and Invariants

Verification is part of the language, not a separate toolchain glue layer.

A property is a parameterized statement about an entity, with nested concrete test instances and sampling fuzz instances:

property "increment from zero" (amount: u64) for Counter with { m_count: 0 } {
    call increment(amount)
    expect state { m_count: amount }

    fuzz { amount in 0..1000 }
}

An invariant explores random traces of routes and checks predicates after each step:

invariant "count stays non-negative" for Counter {
    init { m_count: 0 }

    action increment(amount: u64) {
        bound amount in 0..1000
    }
    action reset() { }

    check m_count >= 0
}

Plain test blocks cover fixed scenarios. The same declarations feed Foundry and Lean harnesses — see Testing and Verification.

Architecture

  .cam source  (+ optional project.yaml)
        |
        v
  Cambrian Transpiler
        |
        +---> --target evm   → Solidity + Foundry
        |
        +---> --target lean  → Lean 4 Lake project

The transpiler parses .cam files (or a multi-file project.yaml), validates them for the selected target, and emits backend-specific artifacts.

What This Book Covers

  • Getting Started — install the toolchain and compile a Counter to EVM and Lean.
  • Language Guide — entities, routes, types, events, errors, std::, shipped contract components, and multi-file projects.
  • Advanced Topics — phased routes, identity members, deterministic addresses.
  • Targets — what each backend emits and how to run it.
  • Testing and Verificationtest, property / fuzz, and invariant blocks.
  • Examples — Counter, Token, Escrow, multi-entity messaging, Governor, Uniswap V2.
  • Reference — CLI, project.yaml, keywords, grammar, validation rule codes.

Installation

The Cambrian transpiler is built from the proprietary cambrian-lang workspace (see Prerequisites for access). It uses the standard Rust toolchain. After prerequisites and a successful build, you can transpile .cam programs to the public targets evm and lean.

Steps

  1. Prerequisites — Rust via rustup; optional Foundry and Lean 4 + Lake for EVM / Lean targets; access to cambrian-lang.
  2. Building from Source — from the workspace root, run cargo build --release and put cambrian-transpiler on your PATH.
  3. Editor Setup (optional) — associate .cam files and install the TextMate grammar for VS Code / Cursor.

Once the binary works, continue with the Quick Start.

Prerequisites

Install only what you need for the targets you plan to use. The transpiler itself requires Rust. EVM and Lean tooling are optional until you exercise those backends.

Rust (required)

Cambrian is a Rust workspace. Install the toolchain with rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Confirm rustc and cargo are on your PATH:

rustc --version
cargo --version

Keep the toolchain current with rustup update.

Access to the transpiler (cambrian-lang)

The Cambrian transpiler and reference contracts live in the cambrian-lang workspace. That tree is not open source and is not available as a public download — there is no public clone URL or release archive.

Cambrian is developed in a proprietary mode. If you are interested in collaborating with the development team or obtaining access to the toolchain, contact [TBD].

Once you have the workspace on your machine, build from its root (see Building from Source). Sample contracts such as contracts/counter.cam are in that tree.

Foundry (for --target evm)

The EVM backend emits Solidity (^0.8.24) and, when the program includes test / fuzz / invariant blocks, a Foundry project (foundry.toml, setup.sh, and tests under test/).

Install Foundry so forge is available:

curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version

Lean 4 and Lake (for --target lean)

The Lean backend emits a Lake project (lakefile.toml, pinned lean-toolchain, and generated modules under Cambrian/). Install Lean 4 via elan:

curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh
elan --version
lake --version

The generated lean-toolchain file pins the Lean version for that output; Lake will fetch it on first lake build.

What you do not need

Node.js is not required for building the transpiler or for the evm or lean workflows described in this book.

Checklist

ComponentNeeded forCheck
Rust / CargoBuilding the transpilerrustc --version
Foundry (forge)Running EVM testsforge --version
elan / LakeBuilding Lean outputlake --version

Next: Building from Source.

Building from Source

These steps assume you already have the cambrian-lang workspace (proprietary; not a public download — see Prerequisites).

Build the transpiler from the workspace root.

Build

cd cambrian-lang
cargo build --release

The binary is:

target/release/cambrian-transpiler

Optionally put it on your PATH:

ln -s "$(pwd)/target/release/cambrian-transpiler" ~/.local/bin/cambrian-transpiler

Invoke it as ./target/release/cambrian-transpiler from the repo, or as cambrian-transpiler once it is on PATH. The examples below assume the binary is on PATH.

CLI usage

cambrian-transpiler <input.cam> [-o <output_dir>] [--target evm|lean]
                    [--dump-ast] [--source-map] [--check-lean]

cambrian-transpiler --project <project.yaml> [--check-lean]
FlagMeaning
-o <dir>Write generated files under <dir>. Default: build/<Entity>-entity/.
--target <name>Backend: evm (default) or lean.
--project <yaml>Load a multi-file project from project.yaml (sources, optional library_paths / imports, target, output dir).
--dump-astPretty-print the parsed program and exit (no codegen).
--source-mapEmit .cam.map JSON alongside generated code (where supported).
--check-leanAfter --target lean, run lake build in the output directory.

Targets at a glance

--targetOutput
evm (default)Flat Solidity under src/, plus Foundry harness when tests are present
leanLean 4 + Lake project

Examples:

# EVM Solidity (default)
cambrian-transpiler contracts/counter.cam -o /tmp/counter-evm --target evm

# Lean 4 project (optionally verify with lake)
cambrian-transpiler contracts/counter.cam -o /tmp/counter-lean --target lean --check-lean

# Multi-file project
cambrian-transpiler --project path/to/project.yaml

# Inspect parse result only
cambrian-transpiler contracts/counter.cam --dump-ast

Workspace tests

From the same repository root:

cargo test --workspace

Next steps

Continue to the Quick Start, or set up Editor Support.

Quick Start

This path takes you from a blank .cam file to generated EVM Solidity and a Lean project, using the same Counter example that ships in cambrian-lang.

What you will build

A Counter entity with three routes:

  • increment — add to the stored count
  • reset — set the count to zero
  • getCount — return the current count

You will see entities, routes, and member transforms, then transpile the same source to more than one backend.

Steps

  1. Your First Entity — walk through the real contracts/counter.cam source.
  2. Compile and Test — transpile to EVM (Foundry), Lean (lake build / --check-lean).

Prerequisites

Complete Installation first. You need at least:

  • cambrian-transpiler on your PATH (or invoke ./target/release/cambrian-transpiler from cambrian-lang)
  • Foundry (forge) if you run the EVM test harness
  • Lean 4 + lake if you build the Lean output

Your First Entity

This chapter walks through the Counter that ships with cambrian-lang at contracts/counter.cam. The source below is the real file, not a simplified stub.

The entity

entity Counter {

    routes {
        increment(amount: u64) => []
        reset() => []
        getCount() -> u64 => [
            return(m_count)
        ]
    }

    m_count: u64 {
        in increment(amount) => m_count + amount
        in reset() => 0
    }
}

Create your own copy if you prefer editing outside the repo:

cp contracts/counter.cam ~/counter.cam

Cambrian sources use the .cam extension.

Entity

An entity is the top-level unit of stateful behaviour: named members plus a public route interface. One .cam file may declare several entities; the CLI processes the first entity in a single-file invocation, while multi-entity programs use --project and a project.yaml.

entity Counter {
    // routes and members...
}

The name Counter becomes the Solidity contract name on --target evm and the generated module name on Lean.

Routes

Routes are the public entry points. On EVM they lower to external / public functions; the source form is always a route with an action list.

routes {
    increment(amount: u64) => []
    reset() => []
    getCount() -> u64 => [
        return(m_count)
    ]
}
RouteRole
increment(amount: u64) => []Takes amount. The body is empty because state changes live in the member transform below.
reset() => []No parameters; clears the count via its transform.
getCount() -> u64 => [return(m_count)]Declares a return type with -> u64 and returns the current member value.

Routes without a -> T return type are state-changing (or effectful) handlers. A route with -> T returns a value; getCount does not list an m_count transform, so the member is left unchanged.

Members and transforms

Members are persistent state. Each member lists how it changes per route, instead of scattering assignments across function bodies.

m_count: u64 {
    in increment(amount) => m_count + amount
    in reset() => 0
}
  • On increment, the new value is the previous m_count plus amount.
  • On reset, the new value is 0.
  • Any route omitted from the transform block leaves the member unchanged.

Bare m_count in a transform expression is the value before that route’s transforms. Inside a transform, ^other is the post-transform value of another member for the same route (the transpiler orders transforms so that reference is well-defined). Counter does not need ^, but it is the rule for multi-member updates later in the language guide.

Compared to Solidity

An imperative Solidity sketch of the same behaviour:

contract Counter {
    uint64 public m_count;

    function increment(uint64 amount) external {
        m_count += amount;
    }

    function reset() external {
        m_count = 0;
    }

    function getCount() external view returns (uint64) {
        return m_count;
    }
}

Both describe the same API. Cambrian gathers each member’s transitions in one block, which keeps “what happens to m_count?” local and is the shape the backends lower for EVM Solidity and Lean specs. Cambrian is not tied to a single chain: the same .cam source targets evm and lean.

Tests in the repo

contracts/counter.test.cam exercises this entity with Cambrian test blocks (call, expect state, expect return). You can merge entity and tests through a small project.yaml when you want Foundry or Lean specs from the same sources—see Compile and Test.

Next steps

Proceed to Compile and Test to transpile Counter to EVM and Lean.

Compile and Test

This page turns contracts/counter.cam into backend projects you can build and test. Run the commands from the cambrian-lang repository root (or pass absolute paths to the .cam file). Examples use ./target/release/cambrian-transpiler; substitute cambrian-transpiler if the binary is on your PATH.

EVM: transpile and Foundry

Entity only

./target/release/cambrian-transpiler contracts/counter.cam \
  -o /tmp/counter-evm --target evm

Output for the plain entity:

/tmp/counter-evm/
  src/
    Counter.sol    # Solidity ^0.8.24 contract

The transpiler prints a reminder such as cd /tmp/counter-evm && bash setup.sh && forge test. With only the entity source (no test / fuzz / invariant declarations), that Foundry scaffolding is not emitted—you still have a readable Counter.sol to compile or deploy with your own Foundry project.

With Cambrian tests (Foundry harness)

To generate foundry.toml, setup.sh, and test/Counter.t.sol, include contracts/counter.test.cam via a project file. Source paths are resolved relative to the YAML file’s directory.

From the cambrian-lang root, write counter-evm.yaml:

name: counter
target: evm
output_dir: /tmp/counter-evm
sources:
  - contracts/counter.cam
  - contracts/counter.test.cam
./target/release/cambrian-transpiler --project counter-evm.yaml
cd /tmp/counter-evm
bash setup.sh    # installs forge-std into lib/ if missing
forge test

setup.sh runs forge install foundry-rs/forge-std --no-commit when needed, then prints the usual forge test / profile hints.

Lean: same source, Lake project

./target/release/cambrian-transpiler contracts/counter.cam \
  -o /tmp/counter-lean --target lean
cd /tmp/counter-lean && lake build

Or let the CLI run Lake for you:

./target/release/cambrian-transpiler contracts/counter.cam \
  -o /tmp/counter-lean --target lean --check-lean

Typical layout (for contracts/counter.cam on current transpilers):

/tmp/counter-lean/
  lakefile.toml
  lean-toolchain
  Cambrian.lean
  Cambrian/
    SimpAttrs.lean
    Prelude.lean          # bundled prelude (Core / Evm pieces live here)
    Generated/
      World.lean
      Counter.lean
      CounterRoutes.lean
      CounterSpec.lean    # when test / property sources are included
  ...

Older docs sometimes listed separate Core.lean / Evm.lean files; those modules are vendored inside Prelude.lean for entity-only emits.

Adding counter.test.cam through --project (with target: lean) also emits specification modules derived from those tests.

Useful flags while iterating

# See the parsed program without writing files
./target/release/cambrian-transpiler contracts/counter.cam --dump-ast

# Emit source maps next to generated code (supported backends)
./target/release/cambrian-transpiler contracts/counter.cam \
  -o /tmp/counter-evm --target evm --source-map

Next steps

Editor Setup

Syntax highlighting for .cam files is optional but useful. The grammar lives in the cambrian-lang tree under cambrian-syntax/.

VS Code / Cursor (TextMate)

The extension is a local TextMate package (package.json + syntaxes/cambrian-clean.tmLanguage.json). It registers the cambrian language for the .cam extension.

Install from the repo

# From your cambrian-lang checkout
cp -R cambrian-syntax ~/.vscode/extensions/cambrian-syntax

For Cursor, use the corresponding extensions directory if it differs from ~/.vscode/extensions (for example under ~/.cursor/extensions).

Reload the window (Developer: Reload Window from the Command Palette).

Verify

Open any .cam file. The status bar should show Cambrian. If not:

  1. Command Palette → Change Language Mode
  2. Choose Cambrian

Or add a workspace association in .vscode/settings.json:

{
  "files.associations": {
    "*.cam": "cambrian"
  }
}

What you get

  • Highlighting for keywords (entity, routes, in, return, pure, fn, let, match, test, …), types, and comments
  • Bracket matching and comment toggle (Cmd+/ / Ctrl+/)
  • Automatic .cam → Cambrian language mode

Other editors

There is no separate plugin for every editor. Practical options:

  • Point the editor at the TextMate grammar cambrian-syntax/syntaxes/cambrian-clean.tmLanguage.json if it supports TextMate / tmLanguage bundles.
  • Associate *.cam with Rust or similar C-family highlighting as a fallback; braces, let, and type annotations read reasonably well.

Next steps

Return to the Quick Start or jump into the Language Guide.

Program Structure

A Cambrian source file uses the .cam extension. It describes one or more entities — units of persistent state that communicate through messages — plus optional shared declarations (types, libraries, events, tests, and so on).

Imports

Cambrian has two unrelated import-style constructs.

Multi-file import "path.cam"

Share declarations across files with a string-literal path. An explicit ./ or ../ path is resolved only relative to the importing .cam file. A bare path (import "token/core.cam") tries that directory first, then each library_paths root from project.yaml:

// math.cam — library file: declarations only, no entities
pure fn min(a: u256, b: u256) -> u256 { if a < b { a } else { b } }

library SafeMath {
    pure fn add(a: u256, b: u256) -> u256
        where (a + b >= a) : throw Overflow() { a + b }
}
// Vault.cam
import "./math.cam"

using SafeMath for u256;

entity Vault {
    routes {
        deposit(amount: u256) => [
            m_balance := m_balance.add(amount)
        ]
    }

    m_balance: u256 {
        in deposit(amount) => m_balance.add(amount)
    }
}

Semantics:

  • Imports are transitive; cycles are rejected at load time.
  • Importable: pure fn, record, enum, type, const, event, error, extern entity, library, using … for …;, and other imports.
  • Not importable: entity, test, fuzz, invariant. Those stay on project entry sources (sources: in project.yaml).
  • Duplicate names across files are the same error as two declarations in one file. imports: does not create a package namespace.
  • In project.yaml mode, list entry points under sources:; transitive imports are picked up automatically. Optional library_paths: / imports: load shared helpers from extra directories (none are built in). If a listed file cannot be found, the error names the directories that were searched. See Multi-File Projects and the Contract Standard Library.

Namespaces always in scope

std::, msg::, sys::, and (on EVM) evm:: need no import. Call stdlib helpers with an explicit prefix (std::math::min); bare names are rejected (V49). See Standard Library and EVM Intrinsics.

Top-level items

A .cam file may contain, in any order:

ItemRole
import "…"Pull in shared declarations (./ file-relative, or bare + library_paths)
type / record / enum / constShared types and constants
pure fnStateless helpers
library / using … for …Reusable pure helpers and method sugar
event / errorLog topics and custom errors
extern entityForeign entity surface (other project or host)
entityStateful unit with routes and members
test / property / fuzz / invariantSpecs and checks (entry sources only)

File layout example

import "./shared.cam"

error Unauthorized();
event Deposited(indexed who: address, amount: U256);

pure fn max(a: u64, b: u64) -> u64 {
    if a >= b { a } else { b }
}

entity Counter {
    routes {
        init setup(owner: address) => []

        deposit(amount: U256)
            where amount > 0 : throw Unauthorized()
        => [
            emit Deposited(msg::sender, amount)
        ]

        view get_balance() -> U256 => [
            return(m_balance)
        ]
    }

    m_owner: address {
        in setup(owner) => owner
    }

    m_balance: U256 {
        in setup(_) => 0
        in deposit(amount) => m_balance + amount
    }
}

Ordering inside an entity

  • Routes live in a single routes { } block.
  • Members are declared in the entity body alongside that block.
  • Type aliases, records, enums, constants, and macros may also appear inside the entity.

Multiple entities

One file (or one project) can define several entities that send to each other via typed addresses and ~>:

entity Ledger {
    routes {
        credit(account: address, amount: U256) => []
    }

    m_balances: HashMap<address, U256> {
        in credit(account, amount) => {
            let current = m_balances.get(account).unwrap_or(0);
            m_balances.set(account, current + amount)
        }
    }
}

entity Shop {
    routes {
        purchase(item_id: u64)
            where msg::value > 0 : throw Unauthorized()
        => [
            credit(msg::sender, msg::value) ~> m_ledger
        ]
    }

    m_ledger: Address<Ledger> {
        in init_shop(ledger) => ledger
    }
}

Naming conventions

These are conventions, not compiler rules (identifiers are [A-Za-z_][A-Za-z0-9_]*):

ElementConventionExample
Entity namesPascalCaseCounter, Token
Route namessnake_casedeposit, get_balance
Member namesm_ + snake_casem_balance, m_owner
Pure functions / macrossnake_casemax, is_owner
Types / records / enumsPascalCaseAmount, Status
Events / errorsPascalCaseTransfer, Unauthorized
ConstantsSCREAMING_SNAKE_CASEMIN_DEPOSIT

Minimal entity

entity Counter {
    routes {
        increment() => []
    }

    m_count: u64 {
        in increment() => m_count + 1
    }
}

Routes declare which entry points the entity exposes; members declare how state changes when those routes run.

Types

Cambrian is a statically typed language. Every variable, parameter, member, and expression has a type that is known at compile time. The type system emphasises explicit widths, checked vs wrapping arithmetic, and clear address / collection types so state transitions stay predictable across backends.

Type Categories

Cambrian types fall into several categories:

CategoryExamplesChapter
Primitivesbool, u64, U256, addressPrimitive Types
GenericsVec<T>, HashMap<K,V>, Option<T>Generic Types
Recordsrecord Pool { ... }Records
Enumsenum Status { Active, Paused }Enums
Type Aliasestype Amount = U256Type Aliases
Tuples(u64, address), (bool, U256, u8)See below

Tuples

Cambrian supports tuple types for grouping a fixed number of values:

pure fn split(total: u64) -> (u64, u64) {
    let half = total / 2;
    (half, total - half)
}

Tuples are positional – their elements are accessed by index or via destructuring in let bindings:

let pair: (u64, bool) = (42, true);

Tuples are most useful as return types for pure functions and as intermediate values in expressions.

Type Annotations

Type annotations appear after a colon in variable bindings, function parameters, member declarations, and return types:

let count: u64 = 0;

pure fn add(a: u64, b: u64) -> u64 {
    a + b
}

In many positions, such as let bindings in blocks, the type can be inferred by the compiler.

Type Casts

The as keyword performs explicit type conversion between compatible types:

let small: u8 = 255;
let big: u64 = small as u64;

Type casting is especially common when working with mixed-width integer arithmetic. See Arithmetic and Logic for details on checked and wrapping operations across types.

Primitive Types

Cambrian provides built-in primitives for booleans, integers, text, bytes, and addresses.

Boolean

TypeValues
booltrue, false

Unsigned integers

TypeRangeTypical use
u80…255Flags, small counters
u160…65 535Small IDs
u320…2³²−1Medium IDs
u640…2⁶⁴−1General counters
u1280…2¹²⁸−1Large counters
usizePlatform-sizedCollection indices

On EVM, narrow integers keep their declared Solidity widths (uint8, …) in structs and storage. Widened uint256 results from arithmetic / timestamps / msg::value are narrowed with casts only when the binding requires it.

Signed integers

i8, i16, i32, i64, i128 — available when negative values are needed.

Large integers

TypeAliasUse
U256uint256Token amounts, balances, wide math
m_balance: U256 {
    in deposit(amount) => m_balance + amount
}

String and bytes

TypeDescription
StringUTF-8 text
bytesRaw byte sequence (b"…" literals)

String↔number conversion is never implicit — use std::str::parse_* / std::str::format (Standard Library).

Address

TypeDescription
addressParty or entity address

Prefer Address<Entity> when sending named messages — see Generic Types.

pubkey

TypeNotes
pubkeyStill a language type (EVM storage maps it like a wide integer). It is not part of the everyday EVM message/system context — prefer address and msg::sender for access control.

Summary

TypeCommon use
boolFlags, conditions
u8u128, i8i128Counters, IDs, offsets
U256 / uint256Balances and amounts
String / bytesText and payloads
addressCallers and destinations

Generic Types

Parameterized types for collections, optionals, and typed entity addresses.

Vec<T>

Dynamic ordered sequence:

m_participants: Vec<address> {
    in register(addr) => m_participants.push(addr)
}
MethodDescription
v.len()Length
v.push(elem)Append (as the tail of a Vec member transform on EVM)
v.get(index)Option<T>
v.is_empty()Emptiness
let empty: Vec<u64> = array();
let nums: Vec<u64> = array(1, 2, 3);

HashMap<K, V>

Key-value map (keys are hashable primitives / address):

m_balances: HashMap<address, U256> {
    in transfer(from, to, amount) => {
        let sender_bal = m_balances.get(from).unwrap_or(0);
        let receiver_bal = m_balances.get(to).unwrap_or(0);
        m_balances
            .set(from, sender_bal - amount)
            .set(to, receiver_bal + amount)
    }
}
MethodDescription
m.get(key)Option<V>
m.set / m.update / m.insertWrite
m.exists(key)Presence
m.remove(key)Delete
m[key]Direct access
let scores: HashMap<address, u64> = {};

Option<T>

some(value) / none:

let result = match m_data.get(key) {
    some(val) => val * 2,
    none => 0
};

Address<Entity>

Typed wrapper around address that carries the target entity at compile time. Named messages require a typed destination (V23 on a plain address). Plain value transfers (~> dest with { value }) may use untyped address.

m_root: Address<RootToken>     // named sends OK
m_backup: address              // value transfer only

Predicting addresses (EVM)

With deterministic_addresses: true on the EVM target, compute a destination without a manual factory:

macro pairAddr(token0: address, token1: address) -> Address<UniswapV2Pair> = {
    UniswapV2Pair.address(token0, token1)
}

routes {
    notify(token0: address, token1: address) => [
        Ping() ~> UniswapV2Pair.address(token0, token1)
    ]
}

Entity.address(args) and the addressOf(...) spelling lower to the same CREATE2 expression on EVM. Singletons use Entity.address() with no arguments. See Deterministic Addresses and Deploy.

FeatureAddress<Entity>address
Named sendAllowedV23
Plain value transferAllowedAllowed
Compile-time route checksYesNo

Records

Records are named product types (structs) that group related fields together. They are declared inside an entity and can be used as member types, route parameters, and intermediate values.

Declaration

A record is declared with the record keyword followed by named, typed fields:

entity Marketplace {
    record Listing {
        seller: address,
        price: U256,
        active: bool
    }

    record Bid {
        bidder: address,
        amount: U256,
        timestamp: u64
    }
}

Each field has a name and a type, separated by a colon. Fields are separated by commas.

Construction

Records are constructed by providing values for all fields:

let listing = Listing {
    seller: msg::sender,
    price: 1000,
    active: true
};

Every field must be specified – there is no default value mechanism. The order of fields in the constructor does not need to match the declaration order, but all fields must be present.

Field Access

Fields are accessed with dot notation:

let price = listing.price;
let who = listing.seller;

if listing.active {
    // handle active listing
}

Functional Update

Cambrian supports a functional update syntax that creates a new record value with some fields changed while keeping the rest:

let updated = listing { active: false };

This produces a new Listing with active set to false and seller and price carried over from the original. The original listing is not mutated.

Functional update is especially useful in member transforms, where you need to produce a new state value based on the current one:

m_listing: Listing {
    in create(seller, price) => Listing {
        seller: seller,
        price: price,
        active: true
    }
    in deactivate() => m_listing { active: false }
    in update_price(new_price) => m_listing { price: new_price }
}

You can update multiple fields at once:

let revised = listing {
    price: new_price,
    active: true
};

Records as Member Types

Records are commonly used as the types of members to group related state:

entity Escrow {
    record Deal {
        buyer: address,
        seller: address,
        amount: U256,
        released: bool
    }

    routes {
        init create(buyer: address, seller: address, amount: U256) => []

        release()
            from Buyer(_)
            where m_deal.released == false : throw 101
        => []
    }

    m_deal: Deal {
        in create(buyer, seller, amount) => Deal {
            buyer: buyer,
            seller: seller,
            amount: amount,
            released: false
        }
        in release() => m_deal { released: true }
    }
}

Records in Collections

Records can be stored in generic collections:

m_bids: Vec<Bid> {
    in place_bid(amount) => m_bids.push(Bid {
        bidder: msg::sender,
        amount: amount,
        timestamp: sys::now
    })
}

Nesting

Records can contain other records as field types:

record Config {
    fee_rate: u64,
    limits: Limits
}

record Limits {
    max_deposit: U256,
    min_deposit: U256
}

Access nested fields with chained dot notation:

let max = config.limits.max_deposit;

Enums

Enums define a type with several named variants. Variants may be unit-only or carry payload data. Declare them at program scope or inside an entity.

Unit variants

enum Status {
    Pending,
    Active,
    Closed,
    Cancelled
}

Data variants

enum Action {
    Transfer(U256),
    UpdateConfig(u64, u64),
    Pause
}

Construction and match

let status = Status::Active;
let action = Action::Transfer(1000);

let label = match status {
    Status::Pending => "waiting",
    Status::Active => "live",
    Status::Closed => "done",
    Status::Cancelled => "cancelled"
};

let amount = match action {
    Action::Transfer(amount) => amount,
    Action::UpdateConfig(a, b) => a + b,
    Action::Pause => 0
};

Use _ as a catch-all. Duplicate / unreachable arms after _ are rejected (V47).

Members and where

enum Phase {
    AwaitingDeposit,
    Funded
}

m_phase: Phase {
    in create(_, _) => Phase::AwaitingDeposit
    in fund() => Phase::Funded
}

routes {
    bid(amount: U256)
        where m_phase == Phase::Funded : throw NotFunded()
    => []
}

EVM lowering (tagged unions)

Solidity has no native sum type. Payload-bearing enums lower to a tagged-union pair:

  • an enum <Name>_Tag with one variant per Cambrian variant;
  • a struct <Name> with a tag field plus one storage field per payload slot across all variants (named <lowercase_variant>_<index>).

Unit-only enums can lower more simply; once any variant carries data, the tagged-union layout applies. The validator may emit an informational E08 note when this happens. Prefer small payloads and clear variant names so the generated struct stays readable.

Type Aliases

Type aliases introduce a new name for an existing type. They improve code readability by giving domain-specific names to generic or primitive types without changing the underlying representation.

Syntax

A type alias is declared with the type keyword inside an entity:

entity Token {
    type Amount = U256;
    type AccountId = address;
    type Timestamp = u64;
}

After declaration, the alias can be used anywhere the original type is expected. The two names are fully interchangeable – no conversion is needed.

Usage

Aliases make member declarations, route signatures, and record fields more expressive:

entity Token {
    type Amount = U256;
    type AccountId = address;

    record Transfer {
        from: AccountId,
        to: AccountId,
        value: Amount
    }

    routes {
        transfer(to: AccountId, amount: Amount)
            where m_balances[msg::sender] >= amount : throw 100
        => []
    }

    m_total_supply: Amount {
        in mint(_, amount) => m_total_supply + amount
    }

    m_balances: HashMap<AccountId, Amount> {
        in transfer(to, amount) => {
            let from_bal = m_balances[msg::sender];
            let to_bal = m_balances.get(to).unwrap_or(0);
            m_balances
                .set(msg::sender, from_bal - amount)
                .set(to, to_bal + amount)
        }
    }
}

Aliases for Complex Types

Aliases are particularly valuable when the underlying type is verbose:

type Approvals = HashMap<address, HashMap<address, U256>>;
type Voters = Vec<address>;
type PriceOracle = Address<Oracle>;

Without aliases, these types would need to be repeated in full wherever they appear, making the code harder to read and maintain.

Scope

Type aliases declared inside an entity are scoped to that entity. They are not visible in other entities within the same file. If two entities need the same alias, each must declare its own.

Aliases vs. Newtypes

Type aliases are transparent – the compiler treats the alias and the original type as identical. This means you cannot use aliases to prevent accidental mixing of conceptually different values:

type UserId = u64;
type ProductId = u64;

// The compiler allows this because both are u64:
let user: UserId = 1;
let product: ProductId = user; // no error

If you need distinct types that the compiler will not conflate, consider using single-field records instead:

record UserId { value: u64 }
record ProductId { value: u64 }

Literals

Literals are fixed values written directly in source code. Cambrian supports numeric literals in several bases, string and byte literals, booleans, collection constructors, and optional values.

Numeric Literals

Decimal

Plain decimal integers are the most common form:

let count = 42;
let large = 1000000;

Underscore Separators

Underscores can appear anywhere within a numeric literal to improve readability. They are ignored by the compiler:

let million = 1_000_000;
let fee = 100_000;
let precise = 1_234_567_890;

Hexadecimal

Hexadecimal literals start with 0x or 0X:

let flags = 0xFF;
let mask = 0xDEAD_BEEF;

Binary

Binary literals start with 0b or 0B:

let bits = 0b101010;
let byte_val = 0b1111_0000;

String Literals

String literals are enclosed in double quotes and produce String values:

let greeting = "hello";
let name = "Cambrian";
let empty_str = "";

Standard escape sequences are supported within string literals.

Byte Literals

Byte literals are prefixed with b and produce bytes values:

let raw = b"raw data";
let payload = b"message body";

Byte literals are useful when working with raw binary data that should not be interpreted as UTF-8 text.

Boolean Literals

The two boolean values:

let active = true;
let paused = false;

Collection Literals

Empty HashMap

An empty HashMap is written with empty braces:

let scores: HashMap<address, u64> = {};

Vec (Array)

Vectors are created with the array function. It accepts zero or more comma-separated values:

let empty: Vec<u64> = array();
let nums = array(1, 2, 3);
let addrs = array(addr1, addr2);

Optional Literals

The Option<T> type has two constructors:

some

Wraps a value in Option:

let found = some(42);
let user = some(msg::sender);

none

Represents the absence of a value:

let missing: Option<u64> = none;

Record Literals

Records are constructed by specifying values for all fields:

record Transaction {
    id: u64,
    value: U256,
    sender: address
}

let tx = Transaction {
    id: 1,
    value: 100,
    sender: msg::sender
};

See Records for full details on record construction and functional update syntax.

Summary

Literal FormTypeExample
DecimalInteger42, 1_000
HexadecimalInteger0xFF, 0xDEAD
BinaryInteger0b101010
Double-quoted stringString"hello"
Byte stringbytesb"raw"
Booleanbooltrue, false
Empty bracesHashMap<K,V>{}
array(...)Vec<T>array(1, 2, 3)
some(value)Option<T>some(42)
noneOption<T>none
Record constructorRecord typeFoo { x: 1 }

Expressions

In Cambrian, most constructs are expressions – they evaluate to a value. This includes arithmetic, comparisons, if/else, match, and blocks. This design enables a concise, functional style where the last expression in a block becomes its value.

Expression Categories

CategoryOperators / FormsChapter
Arithmetic+ - * / % (checked); +% -% *% (wrapping)Arithmetic and Logic
Comparison==, !=, <, <=, >, >=Arithmetic and Logic
Logical&&, ||, !Arithmetic and Logic
Bitwise&, |, ^, <<, >>Arithmetic and Logic
Control flowif/else, matchControl Flow
Iterationfor x in iter { body }See Iteration (for) below
Blocks{ let a = ...; expr }Blocks and Let Bindings
Field accessrecord.field, map[key]Method Calls and Field Access
Method callslist.len(), map.get(key)Method Calls and Field Access
Type castsexpr as TypeMethod Calls and Field Access

Everything Is an Expression

Unlike imperative languages where if is a statement, in Cambrian if/else produces a value:

let fee = if amount > 1000 { 10 } else { 5 };

Similarly, match is an expression:

let label = match status {
    Status::Active => "active",
    _ => "inactive"
};

And blocks evaluate to their last expression:

let result = {
    let a = compute_x();
    let b = compute_y();
    a + b
};

Expressions in Context

Expressions appear in several contexts throughout a Cambrian program:

  • Member transforms – the body of a member transform is an expression that produces the new state value:
m_balance: U256 {
    in deposit(amount) => m_balance + amount
}
  • Where clauses – preconditions are boolean expressions:
where amount > 0 : throw 100
  • Route actions – expressions appear in let bindings, message arguments, and conditional guards:
let fee = amount * 3 / 100;
Transfer(amount - fee) ~> recipient
  • Pure functions – the function body is an expression:
pure fn clamp(val: u64, lo: u64, hi: u64) -> u64 {
    if val < lo { lo }
    else if val > hi { hi }
    else { val }
}

Iteration (for)

Expression-level for produces a value (typically a Vec) by mapping over an iterator:

pure fn double_all(xs: Vec<u32>) -> Vec<u32> {
    for x in xs { x * 2 }
}

pure fn first_n(n: u32) -> Vec<u32> {
    for i in 0..n { i }
}

Common iterator sources:

FormMeaning
for x in lo..hi { … }Half-open integer range
for x in vec_expr { … }Each element of a Vec
for (k, v) in map { … }Key/value pairs from a HashMap
expr.fold(init, |acc, x| …)Accumulator loop (scalar, tuple, or record)

Inside a route action list, a different form sequences side effects per element — no resulting value:

airdrop(amount: U256, recipients: Vec<address>) => [
    for r in recipients => [
        ~> r with { value: amount }
    ]
]

Restrictions: action-level for cannot nest forbidden effects (V29). On EVM, supported shapes lower to a single Solidity loop. See Control Flow for if/match and Route Actions for effectful loops in routes.

Arithmetic and Logic

Cambrian provides arithmetic, comparison, logical, and bitwise operators. A distinguishing feature is its dual arithmetic mode: checked (default) and wrapping, giving explicit control over overflow behavior.

Arithmetic Operators

OperatorOperationExample
+Additiona + b
-Subtractiona - b
*Multiplicationa * b
/Divisiona / b
%Remaindera % b

These operators work on all integer types (u8 through u128, i8 through i128, U256).

let total = price * quantity;
let fee = amount * 3 / 100;
let remainder = total % batch_size;

Checked vs. Wrapping Arithmetic

By default, arithmetic in Cambrian is checked – operations that overflow or underflow will revert the transaction. This is the safe default for financial computations.

Checked Operators (Default)

Standard operators (+, -, *) perform checked arithmetic:

let sum = a + b;       // reverts on overflow
let diff = a - b;      // reverts if b > a for unsigned types

/ and % revert if the divisor is zero (Panic(0x12) on EVM). There is no wrapping division.

On --target lean, the source meaning is still checked, but the default Lean model of +/-/* wraps unless you set lean.numerics: overflow-panic. See Checked vs Wrapping Arithmetic.

Wrapping Operators

When you intentionally want modular arithmetic (values wrap around on overflow), use the %-suffixed operators:

let wrapped_sum = a +% b;   // wraps on overflow
let wrapped_diff = a -% b;  // wraps on underflow
let wrapped_prod = a *% b;  // wraps on overflow

When to Use Each

ScenarioUse
Token balances, financial mathChecked (+, -, *)
Hash computations, bit manipulationWrapping (+%, -%)
Counter that should wrap at maxWrapping (+%)
Any case where overflow is a bugChecked (default)

Example showing both in the same entity:

pure fn safe_add(a: U256, b: U256) -> U256 {
    a + b  // checked -- reverts if overflow
}

pure fn hash_combine(a: u64, b: u64) -> u64 {
    (a *% 31) +% b  // wrapping -- intentional modular arithmetic
}

Comparison Operators

OperatorMeaningExample
==Equala == b
!=Not equala != b
<Less thana < b
<=Less than or equala <= b
>Greater thana > b
>=Greater than or equala >= b

All comparison operators return bool. They are commonly used in where clauses and if conditions:

routes {
    withdraw(amount: U256)
        where m_balance >= amount : throw 100
        && msg::sender == m_owner : throw 101
    => [
        ~> msg::sender with { value: amount }
    ]
}

Logical Operators

OperatorMeaningExample
&&Logical ANDa && b
||Logical ORa || b
!Logical NOT!a

Logical operators work on bool values and short-circuit: && stops at the first false, and || stops at the first true.

macro can_withdraw(amount: U256) -> bool = {
    m_balance >= amount && msg::sender == m_owner
}

Bitwise Operators

OperatorOperationExample
&Bitwise ANDa & b
|Bitwise ORa | b
^Bitwise XORa ^ b
<<Left shifta << n
>>Right shifta >> n

Bitwise operators work on integer types and are useful for flag manipulation and low-level computations:

let flags = 0b1010;
let has_flag = (flags & 0b0010) != 0;
let shifted = value << 8;

Operator Precedence

From highest to lowest precedence:

PrecedenceOperators
Highest! (unary)
*, /, %
+, -
<<, >>
&
^
|
==, !=, <, <=, >, >=
&&
Lowest||

Use parentheses to override precedence when the intent is not obvious:

let result = (a + b) * (c - d);
let check = (x > 0) && (y < max);

Control Flow

Cambrian provides two control flow expressions: if/else for conditional branching and match for pattern matching. Both are expressions – they evaluate to a value.

if / else

The if/else expression evaluates a boolean condition and returns the value of the corresponding branch:

let fee = if amount > threshold { high_fee } else { low_fee };

Basic Form

if condition {
    value_when_true
} else {
    value_when_false
}

Both branches must produce values of the same type when used as an expression.

Chained Conditions

Multiple conditions can be chained with else if:

let tier = if amount >= 10000 {
    "gold"
} else if amount >= 1000 {
    "silver"
} else {
    "bronze"
};

In Member Transforms

if/else is frequently used in member transforms to compute new state conditionally:

m_high_score: u64 {
    in submit(score) => if score > m_high_score {
        score
    } else {
        m_high_score
    }
}

In Pure Functions

Pure functions commonly use if/else as their body:

pure fn max(a: u64, b: u64) -> u64 {
    if a >= b { a } else { b }
}

pure fn clamp(val: u64, lo: u64, hi: u64) -> u64 {
    if val < lo { lo }
    else if val > hi { hi }
    else { val }
}

match

The match expression compares a value against a series of patterns and executes the first matching branch:

let description = match status {
    Status::Active => "running",
    Status::Paused => "paused",
    Status::Closed => "finished",
    _ => "unknown"
};

Syntax

match expression {
    Pattern1 => result1,
    Pattern2 => result2,
    _ => default_result
}

Each arm consists of a pattern, the => arrow, and a result expression. Arms are separated by commas.

Matching Enum Variants

The most common use of match is destructuring enums:

enum Command {
    Deposit(U256),
    Withdraw(U256),
    Freeze
}

let response = match cmd {
    Command::Deposit(amount) => amount,
    Command::Withdraw(amount) => amount,
    Command::Freeze => 0
};

The variable names in data variant patterns (like amount above) bind the associated data for use in the result expression.

Matching Option

Option<T> is commonly matched to handle present and absent values:

let balance = match m_balances.get(account) {
    some(val) => val,
    none => 0
};

Block Bodies

When a match arm needs multiple computations, use a block:

let result = match action {
    Action::Transfer(to, amount) => {
        let fee = amount * 3 / 100;
        amount - fee
    },
    Action::Refund(amount) => amount,
    _ => 0
};

Wildcard Pattern

The _ pattern matches any value. It is typically used as the last arm to handle all remaining cases:

match value {
    0 => "zero",
    1 => "one",
    _ => "other"
}

In Route Actions

match can be used inside route actions for conditional dispatch:

routes {
    execute(action: Action) => [
        match action {
            Action::Transfer(amount) => [
                ~> m_recipient with { value: amount }
            ],
            Action::Pause => []
        }
    ]
}

Exhaustiveness

The compiler checks that match expressions cover all possible variants when matching on an enum. If any variant is missing and no wildcard _ arm is present, the compiler will report an error. This guarantees that no case is accidentally overlooked.

Blocks and Let Bindings

Blocks are sequences of statements enclosed in braces that evaluate to a value. They provide scoped computation with intermediate bindings, making complex expressions readable and composable.

Block Syntax

A block contains zero or more let bindings followed by a final expression. The block evaluates to the value of the final expression:

{
    let a = 10;
    let b = 20;
    a + b
}

This block evaluates to 30.

Let Bindings

The let keyword introduces a named value within a block. Each binding is terminated by a semicolon:

{
    let price = m_item_price;
    let quantity = m_item_count;
    let subtotal = price * quantity;
    let tax = subtotal * 8 / 100;
    subtotal + tax
}

Bindings are immutable – once assigned, a let variable cannot be reassigned.

Type Annotations

Type annotations on let bindings are optional when the type can be inferred:

let count = 42;              // inferred as integer
let name: String = "hello";  // explicit annotation
let bal: U256 = 0;           // explicit when needed for type clarity

Blocks as Expressions

Because blocks are expressions, they can appear anywhere a value is expected:

In Let Bindings

let fee = {
    let rate = if vip { 1 } else { 3 };
    amount * rate / 100
};

In Member Transforms

Blocks are especially useful in member transforms for multi-step state computations:

m_balances: HashMap<address, U256> {
    in transfer(to, amount) => {
        let from = msg::sender;
        let from_bal = m_balances[from];
        let to_bal = m_balances.get(to).unwrap_or(0);
        m_balances
            .set(from, from_bal - amount)
            .set(to, to_bal + amount)
    }
}

In Pure Functions

Pure function bodies are blocks:

pure fn compute_fee(amount: U256, rate: u64) -> U256 {
    let basis_points = rate as U256;
    let fee = amount * basis_points / 10000;
    fee
}

In If/Else Branches

Each branch of an if/else is a block:

let result = if complex_condition {
    let x = compute_a();
    let y = compute_b();
    x + y
} else {
    let fallback = get_default();
    fallback * 2
};

Scope

Variables declared with let are visible only within their enclosing block. They shadow any outer bindings with the same name:

let x = 10;
let result = {
    let x = 20;  // shadows the outer x
    x + 5        // evaluates to 25
};
// x is still 10 here

Blocks in Route Actions

Inside route action lists, let bindings can be used to compute intermediate values:

routes {
    swap(amount: U256) => [
        let fee = amount * 3 / 100;
        let net = amount - fee;
        Transfer(net) ~> m_recipient,
        Transfer(fee) ~> m_treasury
    ]
}

The semicolon after let distinguishes bindings from actions, which are separated by commas.

Method Calls and Field Access

Cambrian uses dot notation for both field access on records and method calls on built-in types. Bracket notation provides indexed access to maps and collections.

Field Access

Record fields are accessed with the dot operator:

let seller = listing.seller;
let is_live = listing.active;
let max = config.limits.max_deposit;

Member state variables are accessed by name within member transforms, where clauses, macros, and route actions:

where m_balance >= amount : throw 100

Map Access

HashMap values are accessed with bracket notation:

let bal = m_balances[account];

Bracket access panics if the key does not exist. For safe access, use the .get() method which returns an Option<V>:

let bal = m_balances.get(account).unwrap_or(0);

Method Calls

Built-in types provide methods that are called with dot notation:

Vec Methods

let size = m_items.len();
let updated = m_items.push(new_item);
let first = m_items.get(0);
let empty = m_items.is_empty();

HashMap Methods

let value = m_map.get(key);
let updated = m_map.set(key, value);
let has_key = m_map.exists(key);
let without = m_map.remove(key);
let size = m_map.len();

Option Methods

let val = opt.unwrap();
let safe_val = opt.unwrap_or(default);
let present = opt.is_some();
let absent = opt.is_none();

String Methods

let size = name.len();

Method Chaining

Methods that return the collection type can be chained:

m_balances
    .set(from, from_bal - amount)
    .set(to, to_bal + amount)

This pattern is common in member transforms where multiple map updates happen atomically.

Type Casts

The as keyword casts a value to a different type:

let wide = narrow_val as u64;
let big = amount as U256;

Casts are necessary when performing arithmetic across different integer widths. The compiler requires explicit casts – implicit widening does not occur in most positions. See Arithmetic and Logic for how this interacts with checked and wrapping arithmetic.

Common Cast Patterns

// Narrowing (may truncate)
let byte_val = large_num as u8;

// Widening (always safe)
let big_val = small_num as u128;

// Between signed and unsigned
let signed = unsigned_val as i64;

Combining Access Patterns

Field access, method calls, and bracket access can be combined freely:

let bidder_balance = m_auctions[auction_id].highest_bid.amount;

let winner = m_results.get(round).unwrap_or(default_result).winner;

Pure Function Calls

Top-level pure functions are called by name (without dot notation):

let fee = compute_fee(amount, rate);
let clamped = clamp(value, min, max);

Macro Invocation

Entity-scoped macros are called with the @ prefix:

where @is_owner() : throw 100

Macros look like function calls but can access entity state. See Macros for details.

Entities

An entity is the core organizational unit in Cambrian: a named unit of persistent state plus the routes that read and update it. Every Cambrian program contains at least one entity. Backends differ in how an entity is hosted (on-chain instance, Lean world model, in-process library), but the .cam shape is the same.

Declaration

An entity is declared with the entity keyword, a name, and a brace-delimited body (names are ordinary identifiers; PascalCase is conventional, not required):

entity Token {
    // type aliases, records, enums, constants, macros
    // routes { ... }
    // member declarations (same level as routes)
}

What an Entity Contains

An entity body can include any of the following, in any order:

ElementPurposeRequired
Type aliasesNamed synonyms for typesNo
RecordsProduct types (structs)No
EnumsSum types (tagged unions)No
ConstantsCompile-time fixed valuesNo
MacrosState-aware helper expressionsNo
routes { }Named entry pointsYes
MembersState variables with transformsYes

A minimal entity needs at least one route and typically at least one member:

entity Counter {
    routes {
        increment() => []
    }

    m_count: u64 {
        in increment() => m_count + 1
    }
}

What an entity is at runtime

Across targets, an entity is an independent unit that:

  • Owns its own persistent state (member declarations in the entity body).
  • Exposes behaviour through its routes.
  • Can send messages to other entities with ~>.

On EVM it is typically deployed at an address; on Lean it appears as state and route transitions in a world model.

Entity-Level Definitions

Type Aliases

entity Token {
    type Amount = U256;
    type AccountId = address;
}

Records

entity Marketplace {
    record Listing {
        seller: address,
        price: U256,
        active: bool
    }
}

Enums

entity Governance {
    enum ProposalStatus {
        Pending,
        Approved,
        Rejected
    }
}

Constants

entity Vault {
    const MIN_DEPOSIT: U256 = 1_000_000;
    const MAX_SIGNERS: u64 = 10;
}

Macros

entity Wallet {
    macro is_owner() -> bool = {
        msg::sender == m_owner
    }

    macro require_funded(amount: U256) -> bool = {
        m_balance >= amount
    }
}

Multiple Entities

A .cam file can define multiple entities. This is the usual approach when several stateful units communicate:

entity Broker {
    routes {
        place_order(item_id: u64, quantity: u64) => [
            Shop::fulfill(item_id, quantity) ~> m_shop
        ]
    }

    m_shop: address {
        in init(shop) => shop
    }
}

entity Shop {
    routes {
        fulfill(item_id: u64, quantity: u64) => [
            Ledger::record_sale(item_id, quantity, msg::sender) ~> m_ledger
        ]
    }

    m_ledger: address {
        in init(_, ledger) => ledger
    }
}

entity Ledger {
    routes {
        record_sale(item_id: u64, quantity: u64, buyer: address) => []
    }

    m_sales: Vec<(u64, u64, address)> {
        in record_sale(item_id, quantity, buyer) =>
            m_sales.push((item_id, quantity, buyer))
    }
}

When entities are defined in the same file, the compiler can verify that messages sent between them match the declared routes.

Entity vs. Pure Functions

Code that does not need state access should be placed in pure functions outside any entity. Pure functions are available to all entities in the same file:

pure fn compute_fee(amount: U256, bps: u64) -> U256 {
    amount * (bps as U256) / 10000
}

entity Token {
    routes {
        transfer(to: address, amount: U256) => [
            let fee = compute_fee(amount, 30);
            // ...
        ]
    }
    // ...
}

See Pure Functions for details.

Routes

Routes are the named entry points of an entity — the primary interface callers use to interact with it. Invoking a route runs its preconditions, member transforms, and action list. How a host dispatches to a route (EVM call, test harness, …) is target-specific; the .cam surface stays the same.

Routes block

entity Wallet {
    routes {
        init setup(owner: address) => []

        deposit()
            where msg::value > 0 : throw ZeroDeposit()
        => []

        withdraw(amount: U256)
            where msg::sender == m_owner : throw Unauthorized()
            && m_balance >= amount : throw InsufficientBalance(m_balance, amount)
        => [
            ~> msg::sender with { value: amount }
        ]

        view get_balance() -> U256 => [
            return(m_balance)
        ]

        pure compute_fee(amount: U256, rate: u64) -> U256 => [
            return(amount * (rate as U256) / 10000)
        ]

        accept receive() => []
    }
}

Route kinds

KindKeywordStateSide effectsUse
Regular(none)Read + writeYesMutations
ViewviewRead onlyNoQueries
PurepureNoneNoStateless computation
Init / constructorinit (or constructor-style)WriteYesOne-time setup
PrivateprivateRead + writeYesIn-entity only; use with call
Receiveaccept receive()Read + writeYesPlain ETH receiver
Fallbackfallback()Read + writeYesUnknown selector

Details:

Private routes

A private route is part of the entity’s logic but not an external entry point. Other routes invoke it with the call action:

entity Service {
    routes {
        run() => [
            call finalize_step()
        ]

        private finalize_step() => []
    }

    m_done: bool {
        in finalize_step() => true
    }
}

See Call (Private Routes) for restrictions and per-target lowering.

Anatomy

[modifier] name(parameters) [from clause] [where clause] [-> ReturnType] => [actions]
PartRequiredDescription
ModifierNoview, pure, init, private, or accept on receive
NameYesRoute identifier (receive / fallback are reserved on EVM)
ParametersYesTyped list (empty for receive / fallback)
From clauseNoSender verification — From Clause
Where clauseNoPreconditions — Where Clause
Return typeNo-> T when returning a value (V42 if return(expr) lacks it)
ActionsYes=> [ … ] (may be empty when only transforms apply)

Clauses

From

release()
    from Buyer(m_buyer_id) : throw Unauthorized()
=> []

Where

withdraw(amount: U256)
    where m_balance >= amount : throw InsufficientBalance(m_balance, amount)
=> []

Prefer named errors — Custom Errors.

Actions

Sends, deploys, emits, conditionals, returns, and local bindings:

transfer(to: address, amount: U256) => [
    let fee = compute_fee(amount);
    Transfer(amount - fee) ~> to,
    Transfer(fee) ~> m_treasury
]

See Route Actions.

Members

Members name the routes that update them:

m_balance: U256 {
    in deposit(_) => m_balance + msg::value
    in withdraw(amount) => m_balance - amount
}

Empty action lists

routes {
    increment() => []
}

m_count: u64 {
    in increment() => m_count + 1
}

The [] is still required syntactically.

Regular Routes

Regular routes are the default route kind. They can read and modify entity state, send messages, deploy entities, and run other route actions. Most routes in a typical Cambrian entity are regular routes.

Syntax

A regular route has no keyword modifier:

routes {
    deposit(amount: U256) => [
        // actions
    ]
}

Parameters

Route parameters are typed and positional:

transfer(to: address, amount: U256) => [
    // to and amount are available here
]

Parameters are available in the action list and are referenced by name in member transforms. A route with no parameters uses empty parentheses:

increment() => []

State Access

Regular routes have full access to entity state through members. State changes are expressed in member transforms, not in the route body itself:

entity Counter {
    routes {
        add(value: u64) => []
    }

    m_total: u64 {
        in add(value) => m_total + value
    }
}

The route body contains actions (sends, deploys, conditionals); state mutation is handled by the member system.

Actions

The action list can contain any combination of sends, deploys, conditionals, let bindings, emits, and evm:: intrinsics:

routes {
    purchase(item_id: u64)
        where msg::value >= m_prices[item_id] : throw 100
    => [
        let price = m_prices[item_id];
        let change = msg::value - price;
        if change > 0 => [
            ~> msg::sender with { value: change }
        ],
        Sold(item_id, msg::sender) ~> m_ledger
    ]
}

With Preconditions

Regular routes commonly use where clauses to enforce invariants:

withdraw(amount: U256)
    where msg::sender == m_owner : throw 101
    && m_balance >= amount : throw 102
=> [
    ~> msg::sender with { value: amount }
]

If any where condition is false, the transaction reverts with the specified error code, and no state changes or actions take effect.

With Sender Verification

The from clause restricts which entity or caller may invoke the route:

credit(account: address, amount: U256)
    from Shop(_)
=> []

See From Clause for details.

Complete Example

A token transfer route combining parameters, preconditions, actions, and member transforms:

entity Token {
    routes {
        transfer(to: address, amount: U256)
            where m_balances[msg::sender] >= amount : throw 100
            && to != msg::sender : throw 101
        => []
    }

    m_balances: HashMap<address, U256> {
        in transfer(to, amount) => {
            let from = msg::sender;
            let from_bal = m_balances[from];
            let to_bal = m_balances.get(to).unwrap_or(0);
            m_balances
                .set(from, from_bal - amount)
                .set(to, to_bal + amount)
        }
    }
}

The route body is [] (empty) because the state change is entirely expressed in the member transform. This is a common and idiomatic pattern.

View Routes

View routes are read-only entry points. They can inspect entity state but cannot modify it, send messages, or perform side effects. Use them to expose queries to callers and other entities.

Syntax

View routes are declared with the view keyword and must specify a return type:

view get_balance() -> U256 => [
    return(m_balance)
]

Return Type

Every view route must declare a return type with -> Type and include a return(value) action:

view get_owner() -> address => [
    return(m_owner)
]

view get_name() -> String => [
    return(m_name)
]

view total_supply() -> U256 => [
    return(m_total_supply)
]

Read-Only Guarantee

View routes have the following restrictions:

AllowedForbidden
Read member valuesModify members (no transforms)
Call pure functionsSend messages (~>)
Use if/else, matchDeploy entities
Compute intermediate valuesSide effects (~>, deploy, emit, evm::)

These constraints are enforced by the compiler. A view route that attempts to trigger a member transform or send a message will produce a compilation error.

Computed Views

View routes can perform computations on state before returning:

view get_balance_of(account: address) -> U256 => [
    return(m_balances.get(account).unwrap_or(0))
]
view is_approved(owner: address, spender: address) -> bool => [
    let allowance = m_allowances.get(owner)
        .unwrap_or({})
        .get(spender)
        .unwrap_or(0);
    return(allowance > 0)
]

Conditional Returns

Views can use if/else and match to determine the return value:

view get_status_label() -> String => [
    return(match m_status {
        Status::Active => "active",
        Status::Paused => "paused",
        Status::Closed => "closed",
        _ => "unknown"
    })
]

Views with Parameters

View routes can accept parameters to query specific data:

view get_listing(id: u64) -> Listing => [
    return(m_listings[id])
]

view get_vote_count(proposal_id: u64) -> u64 => [
    return(m_votes.get(proposal_id).unwrap_or(0))
]

No Member Transforms

View routes do not appear in member transform blocks. Since views cannot modify state, no member declares in get_balance(_) => .... If you find yourself writing a member transform for a view route, you need a regular route instead.

Complete Example

An entity with multiple view routes:

entity Token {
    routes {
        // ... regular routes ...

        view name() -> String => [
            return(m_name)
        ]

        view symbol() -> String => [
            return(m_symbol)
        ]

        view total_supply() -> U256 => [
            return(m_total_supply)
        ]

        view balance_of(account: address) -> U256 => [
            return(m_balances.get(account).unwrap_or(0))
        ]

        view allowance(owner: address, spender: address) -> U256 => [
            return(
                m_allowances.get(owner)
                    .unwrap_or({})
                    .get(spender)
                    .unwrap_or(0)
            )
        ]
    }
}

Pure Routes

Pure routes are stateless computations exposed as entry points. They have no access to entity state and cannot perform side effects. Use them when callers need a pure calculation via the same route interface as other operations.

Syntax

Pure routes are declared with the pure keyword and must specify a return type:

pure compute_fee(amount: U256, rate: u64) -> U256 => [
    return(amount * (rate as U256) / 10000)
]

Restrictions

Pure routes have the strictest constraints of any route kind:

AllowedForbidden
ParametersRead member values
Arithmetic, logicWrite member values
if/else, matchSend messages (~>)
let bindingsDeploy entities
return(value)Platform / host effects
Call pure functionsAccess msg::sender, sys::now

A pure route behaves identically regardless of entity state or message context. Its return value depends only on its parameters.

Use Cases

Pure routes are useful for:

  • Fee calculations that callers can query before a mutating call.
  • Encoding/decoding helpers exposed as routes.
  • Mathematical formulas that other entities need to invoke.
pure max(a: u64, b: u64) -> u64 => [
    return(if a >= b { a } else { b })
]

pure min(a: u64, b: u64) -> u64 => [
    return(if a <= b { a } else { b })
]

pure clamp(val: u64, lo: u64, hi: u64) -> u64 => [
    return(if val < lo { lo } else if val > hi { hi } else { val })
]

Pure Routes vs. Pure Functions

Cambrian has two forms of pure computation:

FeaturePure RoutePure Function
DeclarationInside routes { } with pureTop-level with pure fn
Callable externallyYes (via message)No
Callable internallyAs a routeYes, from any expression
Syntaxpure name(params) -> T => [...]pure fn name(params) -> T { }

Use a pure route when external callers need to invoke the computation. Use a pure function when the computation is only needed internally within the same file.

Example

entity PriceOracle {
    routes {
        pure convert(amount: U256, rate: U256, decimals: u64) -> U256 => [
            let factor = 10 as U256;
            return(amount * rate / factor)
        ]

        pure percentage(value: U256, bps: u64) -> U256 => [
            return(value * (bps as U256) / 10000)
        ]
    }
}

No Member Transforms

Like view routes, pure routes do not appear in member transform blocks. Since they cannot access state, no member can declare a transform in a pure route.

Init Routes

Init routes run once when an entity instance is created. They supply the arguments that member transforms use for initial state. An entity may have at most one init route.

Syntax

Init routes are declared with the init keyword:

init setup(owner: address, name: String) => [
    // initialization actions (optional)
]

Purpose

The init route is invoked exactly once at instance creation. Its primary role is to provide initial values for members through their transforms:

entity Token {
    routes {
        init deploy(
            name: String,
            symbol: String,
            decimals: u8,
            owner: address
        ) => []
    }

    m_name: String {
        in deploy(name, _, _, _) => name
    }

    m_symbol: String {
        in deploy(_, symbol, _, _) => symbol
    }

    m_decimals: u8 {
        in deploy(_, _, decimals, _) => decimals
    }

    m_owner: address {
        in deploy(_, _, _, owner) => owner
    }

    m_total_supply: U256 {
        in deploy(_, _, _, _) => 0
    }
}

Parameter Passing

Init route parameters are passed at deployment time. The deploying entity (or off-chain client) provides these values as part of the deployment transaction:

// In another entity, deploying a Token (identity + init args):
deploy Token("MyToken", "MTK", 18) with { value: init_value }

Init Actions

While the init route’s action list is often empty ([]), it can contain actions that should execute at deployment:

init setup(owner: address, treasury: address) => [
    Welcome(owner) ~> treasury
]

Member Initialization

Every member that needs an initial value must include a transform referencing the init route. Members without an init transform start at their type’s default value (typically zero or empty).

The parameter pattern in a member transform for an init route uses positional matching with _ for unused parameters:

m_owner: address {
    in deploy(_, _, _, owner) => owner
}

m_paused: bool {
    in deploy(_, _, _, _) => false
}

Complete Example

A vault entity with an init route:

entity Vault {
    const MIN_DEPOSIT: U256 = 100;

    routes {
        init create(
            owner: address,
            guardian: address,
            daily_limit: U256
        ) => []

        deposit()
            where msg::value >= MIN_DEPOSIT : throw 100
        => []

        withdraw(amount: U256)
            where msg::sender == m_owner : throw 101
            && m_balance >= amount : throw 102
        => [
            ~> msg::sender with { value: amount }
        ]
    }

    m_owner: address {
        in create(owner, _, _) => owner
    }

    m_guardian: address {
        in create(_, guardian, _) => guardian
    }

    m_daily_limit: U256 {
        in create(_, _, daily_limit) => daily_limit
    }

    m_balance: U256 {
        in create(_, _, _) => 0
        in deposit() => m_balance + msg::value
        in withdraw(amount) => m_balance - amount
    }
}

Constraints

  • An entity can have at most one init route.
  • Init routes cannot be called after deployment — they execute exactly once.
  • Init routes can include where clauses to validate constructor arguments.
  • On EVM with deterministic addresses, construction is split into a factory-guarded initialize(); see Deterministic Addresses.

From Clause

The from clause restricts who may invoke a route by checking msg::sender against an expected entity (or address) identity.

On EVM this lowers to a require(msg.sender == …) style guard.

Syntax

credit(account: address, amount: U256)
    from Shop(m_shop_id) : throw Unauthorized()
=> []

The optional : throw ErrorName(...) supplies a named custom error when the sender check fails (see Custom Errors).

Multiple senders

Join alternatives with | (OR):

update_price(asset: String, price: U256)
    from Oracle(m_oracle_id) | Admin(m_admin_id) : throw Unauthorized()
=> []

From with where

from runs before where:

execute_trade(amount: U256)
    from Broker(m_broker_id) : throw Unauthorized()
    where amount > 0 : throw ZeroAmount()
    && m_status == Status::Active : throw Paused()
=> []

EVM arity rules (V33)

How many arguments from Entity(...) takes depends on addressing mode:

ModeArity
Non-deterministic (default)Only from Entity(addr) — a single address argument
deterministic_addresses: trueArity must match the target entity’s identity member count (zero for singletons: from Registry())

extern entity targets skip V33. Plain address members used as senders can also be compared with where msg::sender == m_owner when you do not need entity-typed address prediction.

In deterministic mode, from Entity(args) compares msg.sender to the same CREATE2 expression as Entity.address(args), so authentication and addressing stay in sync. See Deterministic Addresses.

When to use from

ScenarioPrefer
Public caller actionsNo from (or where on msg::sender)
Callbacks from a known Cambrian entityfrom Entity(…)
Admin / oracle entities with predicted addressesfrom in deterministic mode (EVM)
Plain address stored in a memberwhere msg::sender == m_owner
where msg::sender == m_owner : throw Unauthorized()

Where Clause

The where clause lists preconditions that must hold before a route runs. Each condition pairs with a throw that fires if the condition is false.

Syntax

withdraw(amount: U256)
    where m_balance >= amount : throw InsufficientBalance(m_balance, amount)
=> [
    ~> msg::sender with { value: amount }
]

Prefer named errors (throw ErrorName(args)) so EVM codegen emits typed custom errors. See Custom Errors.

Multiple conditions

Chain conditions with &&. Each has its own throw:

transfer(to: address, amount: U256)
    where m_balances[msg::sender] >= amount : throw InsufficientBalance(m_balances[msg::sender], amount)
    && to != msg::sender : throw SelfTransfer()
    && m_status == Status::Active : throw Paused()
=> []

Conditions evaluate in order. On failure the transaction reverts immediately; no later actions or member transforms run.

Condition expressions

Any boolean expression is valid: comparisons, equality, macro calls (@is_owner()), map lookups, and compounds:

where msg::value > 0 : throw ZeroValue()
where msg::sender == m_owner : throw Unauthorized()
where @is_owner() : throw Unauthorized()
where m_whitelist.exists(msg::sender) : throw NotWhitelisted()
where (m_deadline == 0 || sys::now < m_deadline) : throw Expired()

Where with from

Sender checks run before where:

process(data: bytes)
    from Oracle(m_oracle_id) : throw BadSender()
    where m_status == Status::Active : throw Paused()
    && data.len() > 0 : throw EmptyData()
=> []

Per-phase where

On phased routes, a later phase may attach its own guard that can read var captures from earlier phases:

withdraw(who: address) => [
    fetch: [
        var bal = balanceOf(who) ~> m_token;
    ]
    act where bal > 0 : throw EmptyBalance(): [
        // …
    ]
]

Route-level where runs before any phase and must not reference var bindings (V27). Per-phase where may only see vars from strictly earlier phases (V28). Prefer unphased routes unless you need a synchronous capture or interleaved effects.

Init routes

init setup(owner: address, limit: U256)
    where limit > 0 : throw BadLimit()
=> []

Receive and Fallback

On the EVM target, the route names receive and fallback are reserved and lower to Solidity’s special functions of the same name.

Shape rules

Both routes must:

  • take no parameters;
  • declare no return type;
  • not be marked view or pure.

An entity may declare at most one receive and at most one fallback (V41). Shape violations are V40.

receive

Handles plain native-currency transfers with empty calldata (the usual ETH receiver). Write it with the required accept keyword so the generated function is payable:

entity EthVault {
    routes {
        accept receive() => [
            // credit balance via member transforms, emit, etc.
        ]
    }

    m_deposited: U256 {
        in receive() => m_deposited + msg::value
    }
}

Treat accept receive() as the language spelling of a payable ETH receiver — empty calldata, native value welcome. The generated Solidity is receive() external payable.

fallback

Runs when a call has no matching function selector (and is not a plain ETH send that hits receive):

entity EthVault {
    routes {
        accept receive() => []

        fallback() => [
            // optional catch-all
        ]
    }
}

fallback becomes payable automatically if (and only if) the body or its transforms read msg::value.

Route Actions

Route actions define what happens when a route is invoked. They appear inside the action list after the => arrow. Actions are the imperative side of an entity — they send messages, deploy instances, bind intermediates, and produce return values.

Action List

The action list is enclosed in square brackets. Multiple actions are separated by commas:

transfer(to: address, amount: U256) => [
    let fee = compute_fee(amount);
    let net = amount - fee;
    Transfer(net) ~> to,
    Transfer(fee) ~> m_treasury
]

An empty action list is written as []:

increment() => []

Action Types

ActionSyntaxChapter
Send messageMessage(args) ~> destSend Messages
Plain transfer~> dest with { value: amount }Send Messages
Deploydeploy Entity with { ... }Deploy
Emit eventemit Event(args)Emit Events
Conditionalif cond => [actions] else [actions]Conditional Actions
Returnreturn(value)Return Values
Callcall route(args)Call
Let bindinglet x = expr;See below

Let Bindings in Actions

The let keyword introduces a named intermediate value within the action list. Let bindings are terminated by a semicolon:

swap(amount_in: U256) => [
    let fee = amount_in * 3 / 1000;
    let amount_out = calculate_output(amount_in - fee);
    Transfer(amount_out) ~> msg::sender,
    CollectFee(fee) ~> m_fee_collector
]

Let bindings are evaluated in order. Later bindings and actions can reference earlier bindings.

Execution Order

Actions within a route execute in declaration order:

  1. All let bindings are evaluated.
  2. Sends and deploys are queued.
  3. Conditional actions branch based on their guard.
  4. Member transforms execute atomically with the route.

The combination of actions and member transforms defines the complete effect of a route invocation.

Actions and State

Actions have read access to member values (the pre-transform values). State mutations happen through member transforms, not through actions. This separation is a core design principle of Cambrian.

entity Counter {
    routes {
        increment_and_notify(observer: address) => [
            // m_count here is the *pre-increment* value
            Notify(m_count) ~> observer
        ]
    }

    m_count: u64 {
        in increment_and_notify(_) => m_count + 1
    }
}

To access the post-transform value within the same route, use temporal references. See Temporal References.

Complete Example

entity DEX {
    routes {
        swap(token_in: address, amount_in: U256, min_out: U256)
            where amount_in > 0 : throw 100
        => [
            let reserve_in = m_reserves[token_in];
            let reserve_out = m_reserves[m_paired_token[token_in]];
            let amount_out = (amount_in * reserve_out) / (reserve_in + amount_in);
            if amount_out < min_out => [
                throw SlippageExceeded()
            ] else [
                Token::transfer(msg::sender, amount_out) ~> m_paired_token[token_in]
            ]
        ]
    }
}

Send Messages (~>)

The ~> action is how Cambrian expresses outbound calls and value transfers. On EVM, typed sends lower to external calls; plain transfers lower to native currency sends.

Named (typed) send

Invoke a route on a destination whose type is known:

transfer(amount) ~> m_token
Token::transfer(to, amount) ~> m_token_address

The destination should be an Address<Entity> (or a predicted Entity.address(...)). Named sends to a plain address are rejected (V23). The target entity must be in the project or declared extern entity (E22 otherwise).

Capturing a return value

var bal = balanceOf(who) ~> m_token;

This is a synchronous call: the callee runs, and bal binds the returned value for later actions, transforms, or a per-phase where. Captures are the usual reason to introduce phases; unphased routes already flush storage updates before external calls (checks-effects-interactions).

Plain value transfer

~> recipient with { value: amount }

Transfers native currency without calling a function on the destination.

Message options (with)

On EVM the meaningful option is value — wei attached to the CALL / CREATE:

Worker::start() ~> worker with { value: amount }
~> msg::sender with { value: amount }

Destinations

Notify(data) ~> m_observer
~> to with { value: amount }
~> msg::sender with { value: amount }
Ping() ~> Token.address(token_id)

Multiple sends

distribute(amount: U256) => [
    let share = amount / 3;
    ~> m_partner_a with { value: share },
    ~> m_partner_b with { value: share },
    ~> m_partner_c with { value: amount - share * 2 }
]

Example

entity Token {
    routes {
        transfer(to: address, amount: U256)
            where m_balances[msg::sender] >= amount : throw InsufficientBalance(m_balances[msg::sender], amount)
        => [
            TransferNotification(msg::sender, to, amount) ~> m_observer
        ]
    }

    m_balances: HashMap<address, U256> {
        in transfer(to, amount) => {
            let from = msg::sender;
            m_balances
                .set(from, m_balances[from] - amount)
                .set(to, m_balances.get(to).unwrap_or(0) + amount)
        }
    }

    m_observer: Address<Observer> {
        in init(_, observer) => observer
    }
}

Deploy

The deploy action creates a new entity instance from inside a route.

Basic form

Pass identity / init arguments directly:

deploy UniswapV2Pair(token0, token1)

Optional with { value: … } attaches native currency to the new instance (meaningful on EVM; ignored or unavailable on hosts without a native asset):

deploy Vault(vault_id) with { value: msg::value }

Argument arity must match the target’s constructor surface (identity members plus init-route parameters) — V32.

Deterministic addresses (EVM)

With deterministic_addresses: true in project.yaml, the EVM backend:

  1. Emits a project-wide CambrianFactory.
  2. Lowers deploy Entity(args) to a factory call that performs CREATE2 with the entity’s identity arguments as constructor args.
  3. Calls a factory-guarded initialize(...) for any non-identity init parameters.

Duplicate identical deploy trees for the same entity in one route are rejected (address occupancy collision).

The resulting address matches Entity.address(args) (and the equivalent addressOf spelling). Singletons (no identity members) use Entity.address() with no arguments.

Authors do not hand-roll a factory or salt. Details: Deterministic Addresses.

Predicting the address

let pair = UniswapV2Pair.address(token0, token1);
deploy UniswapV2Pair(token0, token1);
Notify(pair) ~> m_observer

Example

entity EscrowFactory {
    routes {
        create_escrow(buyer: address, seller: address, amount: U256)
            where msg::value >= amount : throw Underfunded()
        => [
            deploy Escrow(buyer, seller, amount) with { value: msg::value }
        ]
    }
}

entity Escrow {
    identity m_buyer: address
    identity m_seller: address

    routes {
        constructor(amount: U256) => []

        release()
            from Buyer(m_buyer) : throw Unauthorized()
        => [
            ~> m_seller with { value: m_amount }
        ]
    }

    m_amount: U256 {
        in constructor(amount) => amount
    }
}

(Exact identity / constructor split depends on your entity design; the factory always keeps CREATE2 occupancy aligned with Entity.address.)

Conditional Actions

Conditional actions allow a route to execute different sets of actions based on runtime conditions. They use an if/else syntax within the action list.

Syntax

if condition => [
    // actions when true
] else [
    // actions when false
]

The else branch is optional:

if condition => [
    // actions when true
]

Basic Usage

Execute an action only when a condition is met:

routes {
    withdraw(amount: U256) => [
        if amount > 0 => [
            ~> msg::sender with { value: amount }
        ]
    ]
}

If/Else

Choose between two action sequences:

routes {
    process(amount: U256) => [
        if amount >= m_threshold => [
            LargeTransfer(amount) ~> m_compliance,
            ~> msg::sender with { value: amount }
        ] else [
            ~> msg::sender with { value: amount }
        ]
    ]
}

Conditions

The condition can be any boolean expression:

Comparisons

if m_balance >= amount => [
    ~> recipient with { value: amount }
]

Macro Calls

if @is_owner() => [
    ~> m_treasury with { value: m_balance }
]

Member State

if m_status == Status::Active => [
    Process(data) ~> m_processor
] else [
    Queue(data) ~> m_queue
]

Compound Conditions

if amount > 0 && m_balance >= amount => [
    ~> msg::sender with { value: amount }
]

Nested Conditionals

Conditional actions can be nested for multi-branch logic:

routes {
    categorize(score: u64) => [
        if score >= 90 => [
            Award("gold") ~> m_rewards
        ] else [
            if score >= 70 => [
                Award("silver") ~> m_rewards
            ] else [
                Award("bronze") ~> m_rewards
            ]
        ]
    ]
}

Conditional Sends

A common pattern is conditionally sending a refund:

routes {
    bid()
        where msg::value > m_highest_bid : throw 100
    => [
        if m_highest_bid > 0 => [
            ~> m_highest_bidder with { value: m_highest_bid }
        ]
    ]
}

This refunds the previous highest bidder only if there was a previous bid.

Conditional Deploy

Deploy an entity only under certain conditions:

routes {
    ensure_vault(user: address) => [
        if m_vaults.exists(user) == false => [
            deploy UserVault(user) with { value: 1_000_000 }
        ]
    ]
}

Conditionals with Let Bindings

Let bindings can precede conditional actions:

routes {
    distribute(total: U256) => [
        let fee = total * 3 / 100;
        let net = total - fee;
        if fee > 0 => [
            ~> m_treasury with { value: fee }
        ],
        ~> m_recipient with { value: net }
    ]
}

Complete Example

entity Escrow {
    routes {
        resolve(approved: bool)
            where msg::sender == m_arbiter : throw 200
        => [
            if approved => [
                ~> m_seller with { value: m_amount },
                Resolved(m_deal_id, true) ~> m_logger
            ] else [
                ~> m_buyer with { value: m_amount },
                Resolved(m_deal_id, false) ~> m_logger
            ]
        ]
    }
}

Return Values

The return action produces a value from a route. It is required in view and pure routes and provides the mechanism for returning computation results to callers.

Syntax

return(expression)

The parentheses are required. The expression inside is evaluated and returned to the caller.

In View Routes

Every view route must include a return action:

view get_balance() -> U256 => [
    return(m_balance)
]

view get_owner() -> address => [
    return(m_owner)
]

In Pure Routes

Every pure route must include a return action:

pure add(a: u64, b: u64) -> u64 => [
    return(a + b)
]

pure compute_fee(amount: U256, bps: u64) -> U256 => [
    return(amount * (bps as U256) / 10000)
]

Computed Returns

The return expression can be any expression, including blocks, conditionals, and function calls:

view get_status_label() -> String => [
    return(match m_status {
        Status::Active => "active",
        Status::Paused => "paused",
        _ => "unknown"
    })
]
view get_effective_balance(account: address) -> U256 => [
    return({
        let raw = m_balances.get(account).unwrap_or(0);
        let locked = m_locked.get(account).unwrap_or(0);
        raw - locked
    })
]

Return with Let Bindings

Let bindings can compute intermediate values before the return:

view get_share(account: address) -> U256 => [
    let balance = m_balances.get(account).unwrap_or(0);
    let total = m_total_supply;
    return(if total > 0 {
        balance * 10000 / total
    } else {
        0
    })
]

Return Type Agreement

The type of the return expression must match the declared return type of the route. The compiler enforces this:

// Correct: returns U256 as declared
view total() -> U256 => [
    return(m_supply)
]

// Error: return type mismatch
// view total() -> U256 => [
//     return("not a number")
// ]

Return in Regular Routes

Regular routes typically do not use return – their purpose is to cause side effects (state changes, message sends) rather than produce values. If a regular route does not need to return a value, the action list contains only sends, deploys, conditionals, and let bindings.

Complete Example

An entity exposing several computed views:

entity Pool {
    routes {
        view total_liquidity() -> U256 => [
            return(m_reserve_a + m_reserve_b)
        ]

        view price(token: address) -> U256 => [
            return(if token == m_token_a {
                m_reserve_b * 1_000_000 / m_reserve_a
            } else {
                m_reserve_a * 1_000_000 / m_reserve_b
            })
        ]

        view share_of(provider: address) -> U256 => [
            let lp = m_lp_balances.get(provider).unwrap_or(0);
            let total_lp = m_total_lp;
            return(if total_lp > 0 {
                lp * (m_reserve_a + m_reserve_b) / total_lp
            } else {
                0
            })
        ]

        pure estimate_output(amount_in: U256, reserve_in: U256, reserve_out: U256) -> U256 => [
            return(amount_in * reserve_out / (reserve_in + amount_in))
        ]
    }
}

Call (Private Routes)

The call action invokes another route on the same entity in-process. It is the usual way to run a private route — a route that external callers cannot invoke directly.

Syntax

call routeName(args)

Arguments must match the target route’s parameter list. There is no ~> or return capture: call runs the private route for side effects (member transforms and its action list) and continues with the caller route afterward.

Private routes

Declare a route with the private modifier:

entity Ledger {
    routes {
        publish(total: U256) => [
            call apply_total(total)
        ]

        private apply_total(total: U256) => []
    }

    m_total: U256 {
        in apply_total(total) => total
    }
}
TargetLowering (conceptual)
EVMprivate Solidity function; not in the public ABI
LeanInternal transition helper in generated modules

Private routes still participate in member transforms (in apply_total(...)).

Restrictions

Allowed in callerForbidden
Regular, init, phased routesview / pure routes calling private (V11)
call after sends, deploys, letcall with var capture (use ~> for return values)

pure routes cannot use call to private routes or any impure construct.

When to use

Use private route + call to split one public entry point into internal steps without exposing extra ABI surface — shared checks, multi-step setup, or helper routes that only make sense inside the entity.

For return values from another entity, use a typed send with var capture (see Send Messages). For events, use Emit Events.

Emit Events

emit Name(args) is a route-body action that writes a declared event to the transaction log.

transfer(dst: address, amount: U256) => [
    emit Transfer(msg::sender, dst, amount)
]

Declare the event first (event Name(...) at program or entity scope), then emit it from any non-pure route. Argument count and types must match the declaration (V35); an undeclared name is V34.

Full declaration rules, indexed parameters, and V36 (max three indexed fields on EVM) are in Events.

Members

Members are persistent state variables owned by an entity. Unlike traditional programming where state is mutated imperatively, Cambrian members declare how they change in response to routes. Each member defines its own transformation logic – this is the member-centric state model.

Declaring Members

Members are declared directly in the entity body, at the same level as routes { } — there is no separate members { } wrapper in the grammar:

m_owner: address {
    in setup(owner) => owner
}

m_balance: U256 {
    in deposit(amount) => m_balance + amount
    in withdraw(amount) => m_balance - amount
}

m_count: u64 {
    in increment() => m_count + 1
    in reset() => 0
}

Member Declaration

Each member has a name, a type, and one or more transforms:

m_name: Type {
    in route_name(params) => new_value_expression
    in another_route(params) => another_expression
}
PartDescription
Namem_ prefix followed by snake_case
TypeAny Cambrian type
TransformsOne in clause per route that modifies this member

The m_ Convention

Member names use the m_ prefix by convention. This distinguishes state variables from local bindings, parameters, and other names. While the compiler does not enforce the prefix, it is universally used in Cambrian code.

Reading Members

Member values are accessible by name in:

  • Where clauses: where m_balance >= amount : throw 100
  • Route actions: ~> m_owner with { value: m_balance }
  • Macros: macro is_owner() -> bool = { msg::sender == m_owner }
  • Other member transforms: in transfer(to, amount) => m_balance - amount

When read in a route’s action list or where clause, the value is the pre-transform value (the value before the current route’s transforms are applied).

Transform Basics

Transforms define the new value of a member after a route executes. The transform body is an expression:

m_count: u64 {
    in increment() => m_count + 1
}

This says: “When the increment route fires, the new value of m_count is the current value plus one.”

See State Transforms for detailed coverage.

Members Without Transforms

If a member does not list a transform for a route, it remains unchanged when that route fires. Only members that explicitly declare in route_name(...) are affected by that route.

Temporal References

Within a single route execution, you may need to reference the post-transform value of a member. The ^ prefix provides this:

m_count: u64 {
    in increment() => m_count + 1
}

m_count_doubled: u64 {
    in increment() => ^m_count * 2
}

Here ^m_count refers to the value of m_count after its transform in the increment route (i.e., m_count + 1).

See Temporal References for details.

Member Types

Members can be of any type:

TypeExample Use
u64, U256Counters, balances, amounts
boolFlags, status indicators
addressOwner, treasury, linked entities
StringNames, labels
HashMap<K, V>Mappings (balances, approvals)
Vec<T>Lists (participants, history)
Option<T>Optional configuration
RecordsGrouped state (deal details, config)
EnumsState machines (status, phase)

Complete Example

entity Token {
    routes {
        init deploy(name: String, symbol: String, owner: address) => []
        mint(to: address, amount: U256)
            where msg::sender == m_owner : throw 100
        => []
        transfer(to: address, amount: U256)
            where m_balances[msg::sender] >= amount : throw 101
        => []
    }

    m_name: String {
        in deploy(name, _, _) => name
    }

    m_symbol: String {
        in deploy(_, symbol, _) => symbol
    }

    m_owner: address {
        in deploy(_, _, owner) => owner
    }

    m_total_supply: U256 {
        in deploy(_, _, _) => 0
        in mint(_, amount) => m_total_supply + amount
    }

    m_balances: HashMap<address, U256> {
        in mint(to, amount) => {
            let current = m_balances.get(to).unwrap_or(0);
            m_balances.set(to, current + amount)
        }
        in transfer(to, amount) => {
            let from = msg::sender;
            let from_bal = m_balances[from];
            let to_bal = m_balances.get(to).unwrap_or(0);
            m_balances
                .set(from, from_bal - amount)
                .set(to, to_bal + amount)
        }
    }
}

State Transforms

State transforms define how member values change when routes fire. Each transform is an in clause inside a member declaration that specifies the new value as a function of the current state and route parameters.

Syntax

m_name: Type {
    in route_name(param1, param2) => new_value_expression
}

The expression after => is evaluated to produce the member’s new value. The current value of the member is accessible by its name (m_name) within the expression.

Simple Transforms

The most common transforms compute the new value from the old value and route parameters:

m_count: u64 {
    in increment() => m_count + 1
    in decrement() => m_count - 1
    in reset() => 0
}
m_balance: U256 {
    in deposit(amount) => m_balance + amount
    in withdraw(amount) => m_balance - amount
}

Parameter Matching

Transform parameters must match the route parameters by position. The names can differ, but the count and order must correspond:

routes {
    transfer(to: address, amount: U256) => []
}

m_balances: HashMap<address, U256> {
    // (to, amount) matches (to: address, amount: U256)
    in transfer(to, amount) => {
        let from = msg::sender;
        m_balances
            .set(from, m_balances[from] - amount)
            .set(to, m_balances.get(to).unwrap_or(0) + amount)
    }
}

Ignoring Parameters

Use _ to ignore route parameters that the member does not need:

m_owner: address {
    in deploy(_, _, owner) => owner  // only needs the third parameter
}

m_total_supply: U256 {
    in mint(_, amount) => m_total_supply + amount  // ignores 'to'
}

Block Transforms

When a transform requires multiple steps, use a block expression:

m_balances: HashMap<address, U256> {
    in transfer(to, amount) => {
        let from = msg::sender;
        let from_bal = m_balances[from];
        let to_bal = m_balances.get(to).unwrap_or(0);
        m_balances
            .set(from, from_bal - amount)
            .set(to, to_bal + amount)
    }
}

Conditional Transforms

Transforms can use if/else and match:

m_high_score: u64 {
    in submit(score) => if score > m_high_score {
        score
    } else {
        m_high_score
    }
}
m_status: Status {
    in toggle() => match m_status {
        Status::Active => Status::Paused,
        Status::Paused => Status::Active,
        _ => m_status
    }
}

Multiple Routes

A member can have transforms for multiple routes. Each in clause specifies how the member changes for one route:

m_balance: U256 {
    in deploy(_, _, _) => 0
    in deposit(amount) => m_balance + amount
    in withdraw(amount) => m_balance - amount
    in transfer_out(_, amount) => m_balance - amount
    in receive(amount) => m_balance + amount
}

If a route is not listed, the member’s value is unchanged when that route fires.

Accessing Other Members

A transform expression can read other members’ values:

m_total_supply: U256 {
    in mint(_, amount) => m_total_supply + amount
}

m_max_reached: bool {
    in mint(_, amount) => (m_total_supply + amount) >= m_cap
}

When referencing other members in a transform, you get their pre-transform values (the values before the current route). To access post-transform values, use temporal references (^m_name). See Temporal References.

Accessing msg:: and sys:: Context

Transforms can access message and system context:

m_last_sender: address {
    in deposit(_) => msg::sender
    in withdraw(_) => msg::sender
}

m_last_update: u64 {
    in deposit(_) => sys::now
    in withdraw(_) => sys::now
}

Record and Collection Transforms

Record Functional Update

m_config: Config {
    in update_fee(new_fee) => m_config { fee_rate: new_fee }
    in update_limit(new_limit) => m_config { max_amount: new_limit }
}

HashMap Updates

m_votes: HashMap<address, bool> {
    in vote() => m_votes.set(msg::sender, true)
}

Vec Updates

m_history: Vec<u64> {
    in record(value) => m_history.push(value)
}

Transform Atomicity

All member transforms for a given route execute atomically. Either all transforms complete successfully, or the entire transaction reverts. There is no partial state update.

m_balance_a: U256 {
    in swap(amount) => m_balance_a - amount
}

m_balance_b: U256 {
    in swap(amount) => m_balance_b + amount
}

Both m_balance_a and m_balance_b update together. If the subtraction in m_balance_a would underflow (with checked arithmetic), neither member is updated.

Temporal References (^x)

Temporal references allow a member transform to access the post-transform value of another member within the same route execution. The ^ prefix distinguishes the “after” value from the default “before” value.

The Problem

Consider a counter and a flag that should be set when the counter exceeds a threshold:

m_count: u64 {
    in increment() => m_count + 1
}

m_over_limit: bool {
    in increment() => m_count > 100  // BUG: uses pre-transform value
}

Here m_count in the m_over_limit transform refers to the value before the increment. If m_count is 100, the increment makes it 101, but m_over_limit sees 100 and evaluates to false. The flag is always one step behind.

The Solution: ^m_name

The ^ prefix accesses the member’s value after its transform has been applied:

m_count: u64 {
    in increment() => m_count + 1
}

m_over_limit: bool {
    in increment() => ^m_count > 100  // CORRECT: uses post-transform value
}

Now ^m_count is m_count + 1 (the result of m_count’s own transform in the increment route). When m_count is 100, ^m_count is 101, and m_over_limit correctly becomes true.

Syntax

^m_member_name

The ^ prefix can be applied to any member that has a transform in the same route. It evaluates to the value that member will have after the route completes.

Common Patterns

Derived State

Compute a member value based on another member’s updated value:

m_balance: U256 {
    in deposit(amount) => m_balance + amount
}

m_is_funded: bool {
    in deposit(_) => ^m_balance > 0
}

Maintaining Invariants

Keep a count consistent with a collection:

m_items: Vec<u64> {
    in add_item(item) => m_items.push(item)
}

m_item_count: u64 {
    in add_item(_) => ^m_items.len()
}

Chained Dependencies

Temporal references can chain through multiple members:

m_price: U256 {
    in update_price(new_price) => new_price
}

m_fee: U256 {
    in update_price(_) => ^m_price * 3 / 100
}

m_net_price: U256 {
    in update_price(_) => ^m_price - ^m_fee
}

Each ^ reference resolves to the post-transform value of the referenced member. The compiler determines the correct evaluation order.

Without ^ (Pre-Transform Values)

Without the ^ prefix, member references always resolve to the pre-transform (current) value:

ReferenceResolves To
m_countValue before the current route fires
^m_countValue after m_count’s transform

This distinction is fundamental to Cambrian’s state model. It enables deterministic computation where every transform sees a consistent snapshot of the “before” state, while still allowing members to depend on each other’s updated values when needed.

Complete Example

A staking entity where rewards are proportional to the updated stake:

entity Staking {
    routes {
        stake(amount: U256)
            where msg::value >= amount : throw 100
        => []

        claim() => [
            ~> msg::sender with { value: m_pending_rewards[msg::sender] }
        ]
    }

    m_stakes: HashMap<address, U256> {
        in stake(amount) => {
            let current = m_stakes.get(msg::sender).unwrap_or(0);
            m_stakes.set(msg::sender, current + amount)
        }
    }

    m_total_staked: U256 {
        in stake(amount) => m_total_staked + amount
    }

    m_reward_rate: U256 {
        in stake(_) => if ^m_total_staked > 0 {
            m_reward_pool / ^m_total_staked
        } else {
            0
        }
    }

    m_pending_rewards: HashMap<address, U256> {
        in claim() => m_pending_rewards.set(msg::sender, 0)
    }
}

Here ^m_total_staked in the m_reward_rate transform uses the updated total (after adding the new stake), ensuring the reward rate reflects the new state.

Rules

  1. ^m_x can only be used in a member transform for a route where m_x also has a transform. If m_x has no transform in route r, then ^m_x in route r is the same as m_x.
  2. Circular temporal dependencies (where ^m_a depends on ^m_b which depends on ^m_a) are detected and rejected by the compiler.
  3. Temporal references are resolved statically at compile time – there is no runtime overhead.

Events

Events declare structured log topics that a route can write with emit. On EVM they mirror the ABI event model and lower to Solidity event declarations plus emit statements.

Declaration

An event may appear at program scope (file top level) or inside an entity (alongside routes / members). Both forms are visible for emit from that entity; program-scope events are shared across entities in the file.

event Transfer(indexed src: address, indexed dst: address, amount: U256);

entity Token {
    event Approval(indexed owner: address, indexed spender: address, amount: U256);

    routes {
        transfer(dst: address, amount: U256) => [
            emit Transfer(msg::sender, dst, amount)
        ]

        approve(spender: address, amount: U256) => [
            emit Approval(msg::sender, spender, amount)
        ]
    }
}

Indexed parameters

Prefix a parameter with indexed to place it in a log topic (searchable / filterable off-chain):

event Transfer(indexed src: address, indexed dst: address, amount: U256);

On the EVM target, a non-anonymous event may have at most three indexed parameters (V36). Extra indexed fields are rejected at validation time.

Emitting

Use the emit action inside a route body:

emit Transfer(msg::sender, dst, amount)

Rules:

RuleCodeMeaning
Event must be declaredV34emit of an unknown name is an error
Arity and types must matchV35Argument count / types must match the declaration (narrow integers may auto-cast to declared widths)
Pure routes cannot emitV11emit is a side effect

See also Emit Events for the action form in a route body.

EVM lowering

CambrianSolidity
event Name(...);event Name(...); (file- or entity-scoped)
emit Name(args)emit Name(args);

Program-scope events appear at file scope in the generated Solidity; entity-scope events live on the generated entity contract.

Custom Errors

Named error declarations describe typed failure ABIs. On EVM they lower to Solidity custom errors and are the preferred way to fail a route, a where guard, or a from check.

Declaration

Errors may appear at program scope or entity scope:

error InsufficientBalance(have: U256, need: U256);

entity Vault {
    error Unauthorized();

    routes {
        withdraw(amount: U256)
            from Owner(m_owner) : throw Unauthorized()
            where m_balance >= amount : throw InsufficientBalance(m_balance, amount)
        => [
            ~> msg::sender with { value: amount }
        ]

        kill()
            from Owner(m_owner) : throw Unauthorized()
        => [
            throw Unauthorized()
        ]
    }
}

Raising errors

FormWhere
: throw ErrorName(args)After a where condition or a from clause
throw ErrorName(args)As a terminator action in a route body

throw / throw Name(...) is a terminator: later actions in the same route (or phase) do not run.

Validation

RuleCode
Error must be declared at program or entity scopeV38
Argument arity / types must matchV39

EVM lowering

CambrianSolidity
error Name(...);error Name(...);
throw Name(args) / : throw Name(args)revert Name(args);

Named vs numeric throw

Older sources may still use a numeric form (: throw 100). Prefer named errors for new code on EVM: they produce typed custom errors that tools and callers can decode. Numeric throw N remains accepted for compatibility and lowers to a string-style revert, but it is not the preferred EVM story.

Extern Entity

An extern entity declares the surface of a foreign entity — one that lives outside the current Cambrian project but that your code still needs to call with typed routes. It is an interface stub: route signatures only, no members, transforms, or bodies.

Declaration

extern entity Token {
    route transfer(amount: U256);
    view route balanceOf(who: address) -> U256;
}

entity Caller {
    routes {
        constructor(t: Address<Token>) => []

        ping(amount: U256) => [
            transfer(amount) ~> m_token
        ]

        view checkBalance(who: address) -> U256 => [
            var b = balanceOf(who) ~> m_token;
            return(b)
        ]
    }

    m_token: Address<Token> {
        in constructor(t) => t
    }
}

Route modifiers on the stub map to the call ABI:

ModifierMeaning on EVM
(none)Ordinary mutating call
view routeRead-only (staticcall-safe)
accept routePayable call surface (attached value: allowed)

Use extern entity when:

  • the target is a third-party or separately built entity (for example an ERC-20 you do not own, on EVM);
  • you want typed Address<Token> members and capturing calls (var x = msg(args) ~> dest) without including the implementation in the project sources.

@solidity_import

Optionally annotate the stub so the EVM emitter imports a real Solidity interface instead of synthesizing interface IName { ... }:

@solidity_import("@openzeppelin/contracts/token/ERC20/IERC20.sol")
extern entity IERC20 {
    route transfer(to: address, amount: U256) -> bool;
    view route balanceOf(who: address) -> U256;
}

The generated .sol gets import "@openzeppelin/...";, and call sites use the imported type name. Pair this with foundry.remappings in project.yaml so Foundry can resolve the path. Keep the Cambrian stub in sync with the imported surface manually — mismatches show up at solc compile time.

Validation

RuleCodeMeaning
Unique nameV30Duplicate extern entity, or collision with a real entity
Unique route namesV31Duplicate route signature inside one extern block
Known send targetE22Typed send whose destination entity is neither in the project nor declared extern entity

Typed sends still resolve the target route (V23 if the route is missing). deploy arity checks (V32) are skipped for extern targets (constructor surface is unknown).

Pure Functions

Pure functions are top-level, stateless computations declared outside any entity. They can be called from any entity in the same file and are guaranteed to have no side effects – their output depends only on their inputs.

Syntax

pure fn name(param1: Type1, param2: Type2) -> ReturnType {
    body_expression
}

The pure fn keywords introduce the function. The body is a single expression (which can be a block) that produces the return value.

Basic Examples

pure fn max(a: u64, b: u64) -> u64 {
    if a >= b { a } else { b }
}

pure fn min(a: u64, b: u64) -> u64 {
    if a <= b { a } else { b }
}

pure fn clamp(val: u64, lo: u64, hi: u64) -> u64 {
    if val < lo { lo }
    else if val > hi { hi }
    else { val }
}

Restrictions

Pure functions have strict constraints:

AllowedForbidden
ParametersRead member values
Arithmetic, logicWrite member values
if/else, matchSend messages
let bindingsAccess msg:: or sys::
Call other pure functionsSide effects (~>, deploy, emit, evm::)
Type castsCall macros

These constraints ensure that pure functions are deterministic, testable, and free of dependencies on entity state.

Block Bodies

Complex pure functions use block expressions with let bindings:

pure fn compute_fee(amount: U256, rate_bps: u64) -> U256 {
    let rate = rate_bps as U256;
    let fee = amount * rate / 10000;
    fee
}
pure fn weighted_average(a: u64, b: u64, weight_a: u64, weight_b: u64) -> u64 {
    let total_weight = weight_a + weight_b;
    let weighted_sum = a * weight_a + b * weight_b;
    weighted_sum / total_weight
}

Calling Pure Functions

Pure functions are called by name from any expression context:

In Route Actions

routes {
    swap(amount_in: U256) => [
        let fee = compute_fee(amount_in, 30);
        let net = amount_in - fee;
        Transfer(net) ~> m_recipient
    ]
}

In Member Transforms

m_fee_collected: U256 {
    in trade(amount) => m_fee_collected + compute_fee(amount, 30)
}

In Where Clauses

routes {
    trade(amount: U256)
        where amount >= min_trade_amount(m_tier) : throw 100
    => []
}

In Other Pure Functions

pure fn abs_diff(a: u64, b: u64) -> u64 {
    if a >= b { a - b } else { b - a }
}

pure fn is_close(a: u64, b: u64, tolerance: u64) -> bool {
    abs_diff(a, b) <= tolerance
}

Pure Functions vs. Macros

FeaturePure FunctionMacro
Declarationpure fn name(...) -> T { ... }macro name(...) -> T = { ... }
LocationTop-level (outside entities)Inside entity
State accessNoneCan read members
ScopeAll entities in the fileEnclosing entity only
Call syntaxname(args)@name(args)

Use pure functions for reusable logic that does not depend on state. Use macros when you need to combine state access with helper logic. See Macros.

Pure Functions vs. Pure Routes

FeaturePure FunctionPure Route
Declarationpure fn name(...) -> T { ... }pure name(...) -> T => [return(...)]
Callable externallyNoYes (via message)
Internal callsYes, from any expressionAs a route
Use caseInternal computationOn-chain utility API

Complete Example

pure fn compute_output(
    amount_in: U256,
    reserve_in: U256,
    reserve_out: U256,
    fee_bps: u64
) -> U256 {
    let fee = compute_fee(amount_in, fee_bps);
    let effective_in = amount_in - fee;
    effective_in * reserve_out / (reserve_in + effective_in)
}

pure fn compute_fee(amount: U256, bps: u64) -> U256 {
    amount * (bps as U256) / 10000
}

entity DEX {
    routes {
        swap(amount_in: U256, min_out: U256)
            where amount_in > 0 : throw 100
        => [
            let out = compute_output(
                amount_in, m_reserve_a, m_reserve_b, 30
            );
            if out < min_out => [
                // slippage protection
            ] else [
                Token::transfer(msg::sender, out) ~> m_token_b
            ]
        ]
    }
}

Libraries and Using

Cambrian offers two complementary ways to reuse pure helpers: named library blocks and using … for T method-call sugar.

library Name { … }

A library is a named scope of pure fn, const, and type items only. Bodies may not touch entity state, msg::*, or temporal references — the same purity envelope as a free pure fn.

library SafeMath {
    pure fn add(a: u256, b: u256) -> u256
        where (a + b >= a) : throw Overflow() { a + b }

    pure fn sub(a: u256, b: u256) -> u256
        where (a >= b) : throw Underflow() { a - b }
}

Call library functions with a qualifier, or attach them via using (below).

using Lib for T

Attach every suitable function from a library as a method on type T:

using SafeMath for u256;

entity Vault {
    routes {
        deposit(amount: u256) => [
            m_balance := m_balance.add(amount)
        ]
    }

    m_balance: u256 {
        in deposit(amount) => m_balance.add(amount)
    }
}

recv.fn(args) rewrites to Lib.fn(recv, args) (or the free pure fn form) before codegen. Chained calls such as x.add(1).mul(2) work when each step’s return type matches the next receiver.

using { fn1, fn2 } for T

Attach selected free pure fns without a library wrapper:

pure fn double(x: u256) -> u256 { x * 2 }
pure fn triple(x: u256) -> u256 { x * 3 }

using { double, triple } for u256;

entity Foo {
    routes {
        bump() => []
    }

    m_n: u256 {
        in bump() => m_n.double().triple()
    }
}

Rules of thumb

  • The first parameter of each attached function must match the for type.
  • Method names must not collide with built-in methods on Vec, HashMap, String, or Address.
  • Libraries and free pure fns may live in imported .cam files. See Program Structure and the Contract Standard Library.

EVM lowering

Each Cambrian library becomes a Solidity library with internal pure (or equivalent) functions. Call sites use LibName.fn(args). Free pure fns outside a library remain top-level Solidity helpers.

Macros

Macros are entity-scoped helper expressions that can access member state. They provide reusable logic fragments for conditions, computations, and assertions that need to reference the entity’s persistent state.

Syntax

macro name() -> ReturnType = {
    body_expression
}

Macros are declared inside an entity body, alongside routes { … } and member fields — not inside route action lists.

Basic Examples

entity Wallet {
    macro is_owner() -> bool = {
        msg::sender == m_owner
    }

    macro is_funded(amount: U256) -> bool = {
        m_balance >= amount
    }

    macro require_active() -> bool = {
        m_status == Status::Active
    }
}

Invocation

Macros are called with the @ prefix:

routes {
    withdraw(amount: U256)
        where @is_owner() : throw 100
        && @is_funded(amount) : throw 101
    => [
        ~> msg::sender with { value: amount }
    ]
}

The @ prefix distinguishes macro calls from pure function calls and makes it clear that the expression accesses entity state.

Parameters

Macros can accept parameters:

macro has_allowance(owner: address, spender: address, amount: U256) -> bool = {
    m_allowances.get(owner)
        .unwrap_or({})
        .get(spender)
        .unwrap_or(0) >= amount
}

State Access

Unlike pure functions, macros can read member values. This is their primary advantage:

macro available_balance() -> U256 = {
    m_balance - m_locked
}

macro is_whitelisted(account: address) -> bool = {
    m_whitelist.exists(account)
}

macro current_price() -> U256 = {
    if m_supply > 0 {
        m_reserve * 1_000_000 / m_supply
    } else {
        0
    }
}

Context Access

Macros can also access msg:: and sys:: context:

macro is_owner() -> bool = {
    msg::sender == m_owner
}

macro is_expired() -> bool = {
    sys::now > m_deadline
}

macro has_sufficient_value(required: U256) -> bool = {
    msg::value >= required
}

Common Patterns

Access Control

macro is_admin() -> bool = {
    msg::sender == m_admin
}

macro is_operator(account: address) -> bool = {
    m_operators.exists(account)
}

State Validation

macro is_active() -> bool = {
    m_status == Status::Active && sys::now < m_deadline
}

macro has_quorum(proposal_id: u64) -> bool = {
    m_vote_counts[proposal_id] >= m_quorum
}

Computed Values

macro fee_for(amount: U256) -> U256 = {
    amount * m_fee_rate / 10000
}

macro net_amount(amount: U256) -> U256 = {
    amount - @fee_for(amount)
}

Macro Calling Macro

Macros can call other macros in the same entity:

macro can_execute(amount: U256) -> bool = {
    @is_owner() && @is_funded(amount) && @is_active()
}

Where to Use Macros

Macros can appear in:

ContextExample
Where clauseswhere @is_owner() : throw 100
Route actionslet fee = @fee_for(amount);
Member transformsin deposit(amount) => m_balance + @fee_for(amount)
Conditional actionsif @is_active() => [...]

Macros vs. Pure Functions

FeatureMacroPure Function
State accessYes (reads members)No
Context accessYes (msg::, sys::)No
ScopeEnclosing entity onlyAll entities in the file
Call syntax@name(args)name(args)
DeclarationInside entityTop-level

Use macros when the helper needs entity state. Use pure functions when the computation depends only on its arguments.

Complete Example

entity Multisig {
    macro is_signer() -> bool = {
        m_signers.exists(msg::sender)
    }

    macro has_enough_confirmations(tx_id: u64) -> bool = {
        m_confirmation_count.get(tx_id).unwrap_or(0) >= m_required
    }

    macro is_pending(tx_id: u64) -> bool = {
        m_executed.get(tx_id).unwrap_or(false) == false
    }

    routes {
        submit(to: address, amount: U256)
            where @is_signer() : throw 200
        => []

        confirm(tx_id: u64)
            where @is_signer() : throw 200
            && @is_pending(tx_id) : throw 201
        => []

        execute(tx_id: u64)
            where @is_signer() : throw 200
            && @is_pending(tx_id) : throw 201
            && @has_enough_confirmations(tx_id) : throw 202
        => [
            ~> m_transactions[tx_id].to with {
                value: m_transactions[tx_id].amount
            }
        ]
    }
}

Standard Library

Cross-target helpers live under the std:: namespace. Like msg:: and sys::, std:: is always in scope — no import is required.

Ready-to-deploy token and vault entities, plus extra integer helpers, live in the separate Contract Standard Library (stdlib/ in cambrian-lang). Those are ordinary .cam files you import or list in project.yaml; they are not std:: names.

Bare legacy names such as min(a, b) or sha256(data) are rejected (V49). Always write the qualified form.

std::math

All functions are pure. Operand types must match unless the signature documents widening (same rules as binary arithmetic).

FunctionRole
std::math::min(a, b)Smaller of two values
std::math::max(a, b)Larger of two values
std::math::abs(x)Absolute value (signed)
std::math::clamp(x, lo, hi)Clamp to [lo, hi]
std::math::muldiv(x, y, z)(x * y) / z with a wide intermediate
std::math::muldivmod(x, y, z)Quotient and remainder, wide intermediate
std::math::divmod(x, y)(quotient, remainder)
std::math::divc(x, y)Division rounding up
std::math::divr(x, y)Division rounding to nearest
std::math::sign(x)-1, 0, or 1 (signed)
std::math::minmax(a, b)(min, max) pair
std::math::modpow2(x, n)x % 2^n (mask semantics)
std::math::pow(x, n)Exponentiation (n: u64)

Numeric types: supported integer widths (u8u128, i8i128, U256) where the operation is defined. Unsupported combinations fail at transpile time.

A user pure fn min(...) does not replace std::math::min — call the std symbol with the prefix.

std::str

Parsing

std::str::parse_u64(s: String, radix: u64) -> Option<u64>
std::str::parse_u8(s, radix) -> Option<u8>
std::str::parse_i64(s, radix) -> Option<i64>
std::str::parse_uint(s, radix) -> Option<u64>   // alias
std::str::parse_int(s, radix) -> Option<i64>    // alias

Parse all characters of s with radix in 2..=36. Leading 0x / 0X forces base 16 (radix must be 16 or 0). Overflow, empty string, bad digit, or bad radix yields none.

match std::str::parse_u64(s, 10) {
    some(n) => n,
    none => 0,
}

Formatting

std::str::format(fmt: String, ...) -> String

Phase-1 format specs:

SpecMeaning
{}Default display
{:w}Minimum width w
{:0w}Zero-pad to width w (for example {:06}000042)

Arguments may be integers or strings.

std::crypto

FunctionSignatureSemantics
std::crypto::sha256(bytes) -> [u8; 32]SHA-256 digest

Prefer std::crypto::sha256 for portable digests. On EVM-only call sites, evm::sha256 remains available for explicit platform intent — see EVM Intrinsics.

Strict typing (V50)

There is no implicit string↔number coercion in member transforms, returns, or sends:

  • String → numeric member without std::str::parse_*V50
  • numeric expression assigned / returned as String without std::str::formatV50

Collection helpers (len, map, fold, …) stay method syntax on Vec, HashMap, and Option — they are not bare global functions.

See also

Contract Standard Library

The language builtins under std:: (min, sha256, …) are always in scope — see Standard Library. Separately, cambrian-lang ships reusable .cam components under stdlib/: integer helpers plus ready-to-deploy token and vault entities, each with tests.

These files are ordinary Cambrian sources. Point library_paths at the stdlib/ directory (or copy the files you need) and either import the shared helpers or list an entity under sources:. See Multi-File Projects.

Layout

PathKindRole
stdlib/math.camhelpersInteger sqrt and a wide mul_div
stdlib/token/erc20-core.camhelpersBalance / allowance / EIP-712 helpers
stdlib/token/ERC20.camentitySingleton ERC-20 + EIP-2612 permit
stdlib/token/ERC20Multi.camentitySame token with an identity (several instances)
stdlib/token/erc7943-core.camhelpersShared ERC-7943 permission helpers
stdlib/token/ERC7943Min.camentityPermissioned token: who may move value
stdlib/token/ERC7943.camentityPermissioned token plus exact accounting
stdlib/vault/erc4626-core.camhelpersAsset interface and share/asset conversion
stdlib/vault/ERC4626Min.camentityERC-4626 route set, textbook ratios
stdlib/vault/ERC4626.camentityERC-4626 with virtual shares and counted deposits

Helper files hold only shared declarations (pure fn, extern entity, …). Entity files are the contracts you deploy; list those under sources: together with their *.test.cam / *.fuzz.cam / *.invariant.cam suites.

Using a component

From a project that can see the cambrian-lang tree:

name: my-app
target: evm
deterministic_addresses: true
library_paths:
  - ${CAMBRIAN_STDLIB}    # directory that contains math.cam and token/
imports:
  - math.cam
  - token/erc20-core.cam
sources:
  - MyVault.cam
// MyVault.cam
import "math.cam"

entity MyVault {
    routes {
        // …
    }
    // …
}

A path that starts with ./ or ../ is always relative to the importing file — that is how files inside stdlib/ refer to each other (import "./erc20-core.cam"). A path without that prefix also searches each library_paths entry. Names from imported files join the same program-wide namespace as your own pure fns; two files cannot declare the same name.

To deploy a shipped entity as-is, put it on sources: instead of importing it:

sources:
  - token/ERC20.cam
  - token/ERC20.test.cam

Math helpers (math.cam)

Prefer built-in std::math for min, max, clamp, and ordinary muldiv. math.cam exists for two operations the builtins do not cover:

FunctionWhat it does
sqrt(x)Integer square root. There is no std::math::sqrt.
mul_div(a, b, c)⌊a·b/c⌋ with a 512-bit intermediate. std::math::muldiv is (a * b) / c and reverts if a·b overflows U256, even when the quotient fits.

Do not re-export min / max from this file: a user pure fn of that name shadows the builtin for the whole program.

Tokens

ERC20 and ERC20Multi

Fungible token with transfer / transferFrom / approve, metadata views, gated mint / holder burn, and EIP-2612 permit.

  • ERC20 — one deployment. Use this for an application, governance, or wrapped-asset token.
  • ERC20Multi — the same routes plus identity m_token_id. Deploy several tokens from one codebase at CREATE2 addresses (ERC20Multi.address(0), ERC20Multi.address(1)). That is what an AMM harness needs; a single-deployment token should stay on ERC20.

transfer / transferFrom / approve return true so Solidity IERC20 callers can decode the result. A rejected transfer throws; the bool is for the ABI, not a second error channel.

Deviation: a zero-value transfer reverts. ERC-20 requires those to succeed. Drop the amount > 0 guard if you must accept them, and re-run the shipped tests.

ERC7943Min and ERC7943

Permissioned (RWA-style) tokens on top of the ERC-20 helpers.

  • ERC7943Min — operator-controlled forcedTransfer, freeze, canSend / canReceive / canTransfer. Claims are about who may move value, and they stay meaningful over a rebasing or fee-on-transfer base.
  • ERC7943 — the same permission routes plus metadata, permit, holderCount, and exact balance arithmetic (frozen + unfrozen = balance, seizures move amount). Adopt this only when the underlying asset does not rebase or take fees.

One privileged key (m_operator) is both minter and compliance operator. Split those roles in an outer contract if you need two keys.

Vaults

Both vaults implement ERC-4626 (deposit / mint / withdraw / redeem, convert/preview/limit views) and treat the share as an ERC-20. They talk to the underlying asset through an extern entity — point m_asset at whatever ERC-20 you deploy, including ERC20Multi.

Rounding always favours the vault (against the caller) on the four write routes.

  • ERC4626Min — textbook assets/shares ratios, including an empty-vault branch. Interface and revert shape only; not safe against an untrusted first depositor.
  • ERC4626 — virtual shares plus deposits credited from the amount the asset actually delivered (so a fee-on-transfer token cannot mint shares against value the vault never received). The first-depositor attack becomes expensive; it does not revert. A rebasing asset can still move the vault balance after the second read.

The vault routes capture a return from a call into the asset. The Lean backend does not yet lower that pattern, so the shipped Lean suite covers the token entities; exercise the vaults on EVM (forge test).

Tests

Each entity has matching *.test.cam, *.fuzz.cam, and *.invariant.cam files in the same directory. The token project (stdlib/token/project.yaml) and vault project (stdlib/vault/project.yaml) are the EVM entry points. The vault project also deploys ERC20Multi as the asset under test.

The Token example in this book is a short teaching merge, not these components. Start from stdlib/token/ when you want a deployable ERC-20.

Message Context (msg::)

The msg:: namespace describes the inbound call that triggered the current route. Values are available in route actions, where / from clauses, macros, and member transforms — not in pure fn or pure routes.

Primary fields (EVM)

FieldTypeDescriptionSolidity lowering
msg::senderaddressCaller of this callmsg.sender
msg::valueintegerNative currency attached to the callmsg.value

These are the fields you should rely on for access control and payable logic.

Optional time alias

FieldNotes
msg::timestampLowers to block.timestamp on EVM. Prefer sys::now / sys::timestamp when you mean block time, so message context and system context stay distinct.

msg::sender

macro is_owner() -> bool = {
    msg::sender == m_owner
}

routes {
    withdraw(amount: U256)
        where msg::sender == m_owner : throw Unauthorized()
    => [
        ~> msg::sender with { value: amount }
    ]
}

In transforms:

m_last_caller: address {
    in deposit(_) => msg::sender
}

m_balances: HashMap<address, U256> {
    in deposit(amount) => {
        let current = m_balances.get(msg::sender).unwrap_or(0);
        m_balances.set(msg::sender, current + amount)
    }
}

msg::value

routes {
    deposit()
        where msg::value > 0 : throw ZeroDeposit()
    => []
}

m_balance: U256 {
    in deposit() => m_balance + msg::value
}

Routes (or transforms) that read msg::value are treated as payable on EVM so callers can attach native currency.

Usage contexts

ContextExample
Where clauseswhere msg::sender == m_owner : throw Unauthorized()
Route actions~> msg::sender with { value: amount }
Member transformsin deposit() => msg::value
Macrosmacro is_owner() -> bool = { msg::sender == m_owner }

For block / chain / balance environment reads, see System Context.

System Context (sys::)

The sys:: namespace describes the execution environment for the current route (time, self address, balance, and related host fields). Values are available in route actions, where clauses, macros, and member transforms — not in pure fn or pure routes.

Fields commonly used on EVM

FieldDescriptionTypical EVM lowering
sys::now / sys::timestampBlock / host timestampblock.timestamp
sys::block_numberBlock numberblock.number
sys::chainidChain idblock.chainid
sys::addressThis entity’s addressaddress(this)
sys::balanceThis entity’s native balanceaddress(this).balance
sys::coinbaseBlock coinbaseblock.coinbase
sys::basefeeBlock base feeblock.basefee
sys::blobbasefeeBlob base fee (EIP-4844)block.blobbasefee
sys::prevrandaoBlock randomnessblock.prevrandao
sys::gas_leftRemaining gasgasleft()
sys::origintx.origin (use sparingly)tx.origin
sys::gaspriceTransaction gas pricetx.gasprice

For property / invariant ctx blocks, the validated context names are the portable subset: msg::{sender,value} and sys::{now,timestamp,chainid,block_number,balance} (T20).

Time guards

routes {
    claim()
        where (sys::now >= m_unlock_time) : throw TooEarly()
    => [
        ~> msg::sender with { value: m_amount }
    ]
}
m_last_action: u64 {
    in deposit(_) => sys::now
    in withdraw(_) => sys::now
}

Own address and balance

routes {
    sweep(to: address) => [
        ~> to with { value: sys::balance }
    ]
}

Prefer sys::balance for “this entity’s balance.” Use evm::balance(addr) when you need another account’s balance on EVM (see EVM Intrinsics).

Not for pure code

pure fn and pure routes cannot read sys::* (or msg::*) — those namespaces are impure by definition.

EVM Intrinsics (evm::)

The evm:: namespace exposes EVM-specific primitives. It is always in scope on EVM-domain targets. Using evm::* off the EVM domain is rejected (E12).

Pure intrinsics

Usable inside pure fn bodies as well as routes:

IntrinsicReturnsLowers to
evm::ecrecover(hash, v, r, s)addressecrecover(...) (address(0) on bad signature)
evm::keccak256Packed(a, b, ...)bytes32keccak256(abi.encodePacked(a, b, ...))
evm::sha256(data)bytes32sha256 precompile
evm::ripemd160(data)bytes20ripemd160 precompile

Impure intrinsics

Read live account or block context — not allowed inside pure fn (V4):

IntrinsicReturnsLowers to
evm::balance(addr)u256addr.balance
evm::blockhash(n)bytes32blockhash(n)

Example (EIP-2612 style)

routes {
    permit(
        owner: address,
        spender: address,
        value: U256,
        deadline: U256,
        v: u8,
        r: bytes32,
        s: bytes32
    )
        where sys::timestamp <= deadline : throw Expired()
    => [
        let digest = evm::keccak256Packed(/* EIP-712 fields… */);
        let recovered = evm::ecrecover(digest, v, r, s);
        // … compare recovered to owner, update allowance …
    ]
}

Portable digests

Prefer std::crypto::sha256 when the same source should stay meaningful across targets. Use evm::sha256 when you specifically want the EVM precompile at the call site.

Multi-File Projects

A project.yaml describes a multi-file Cambrian project: which .cam sources to load, which backend to target, where to write output, and optional test / Foundry / Lean knobs.

CLI

cambrian-transpiler --project path/to/project.yaml

Entry sources listed under sources: are merged into one program. Transitive import "./other.cam" edges are followed automatically — shared helper files need not be enumerated. Optional library_paths: / imports: pull helpers from a search root (for example the contract standard library) without listing them as project entries.

Minimal example

name: uniswap-v2
target: evm
deterministic_addresses: true
output_dir: build/
sources:
  - ERC20.cam
  - UniswapV2Pair.cam
  - UniswapV2Factory.cam

Common keys

KeyRole
nameProject name (informational / packaging)
targetBackend: typically evm or lean
sourcesEntry-point .cam files (relative to the YAML)
library_pathsOptional extra directories to search for shared .cam files (${VAR} expanded; absolute or relative to the YAML). None are built in.
importsShared declaration files to load (helpers, not entities). Looked up next to the YAML, then in each library_paths directory. Names join the same program-wide namespace.
output_dirWhere generated code is written (default build/)
deterministic_addressesWhen true, EVM uses CREATE2 + auto CambrianFactory (see Deterministic Addresses)
foundryFoundry / solc options (optimizer, via_ir, remappings, …)
fuzzShared fuzz defaults (runs, seed, shrink, …)
invariantShared invariant defaults (runs, depth, fail_on_revert, …)
leanLean-target knobs (numerics, proof_helpers, intrinsics, …)

Foundry remappings

When using @solidity_import on an extern entity:

foundry:
  optimizer: true
  via_ir: true
  remappings:
    - "@openzeppelin/=lib/openzeppelin-contracts/"
    - "forge-std/=lib/forge-std/src/"

Tests

fuzz:
  runs: 64
  seed: 1
  shrink: true

invariant:
  runs: 16
  depth: 16
  fail_on_revert: false

Imports vs project sources

  • sources: — entities and their tests, fuzz, and invariants. These are the files you own and deploy.
  • imports: — shared helpers listed in YAML (pure fn, types, library, event / error, extern entity, …). Looked up next to the YAML, then in each library_paths directory.
  • import "./lib.cam" — always relative to the importing file. A missing file is an ordinary “file not found”; library_paths is not searched.
  • import "token/erc20-core.cam" (no ./ / ../) — same directory as the importer first, then each library_paths entry.
  • An imported file must not declare entity, test, fuzz, or invariant — put those on sources:. Imported names are not wrapped in a package; a clash is the same duplicate-name error as two declarations in one file.
library_paths:
  - ${CAMBRIAN_STDLIB}
imports:
  - math.cam
  - token/erc20-core.cam

See Program Structure, project.yaml, and the Contract Standard Library.

Full key list

For the complete schema (including nested Foundry profiles and Lean emission options), see the reference page project.yaml.

Phased Routes

Most routes are unphased: every member transform runs as one atomic step, then every effect (~>, deploy, emit, …) runs afterward. That covers the common case and matches the checks–effects–interactions (CEI) discipline on EVM.

Reach for named phases only when you need something the unphased model cannot express:

  1. var capture — a synchronous return from an external call that later transforms or guards depend on.
  2. Interleaving — an effect must run between state updates (or between other effects) rather than after all of them.

Otherwise keep the route unphased. Fewer phases are easier to read and audit.

Syntax

A phased route body is a sequence of tag: [ … ] blocks. Phases run in declaration order:

castVote(proposal_id: U256, support: u8) => [
    read: [
        var weight = getVotes(msg::sender) ~> m_token;
    ]
    tally where weight > 0 : throw 100 : [
    ]
]

Member transforms that participate in a phased route must name the same phase tag:

m_for: HashMap<U256, U256> {
    in castVote(proposal_id, support) =>
        tally: if support == 1 {
            m_for.update(proposal_id, m_for[proposal_id] + weight)
        } else {
            m_for
        }
}

Prefer unphased on EVM

For an unphased route the EVM backend flushes all member SSTOREs before any external call. You get CEI without writing phases:

transfer(to: address, amount: U256)
    where amount > 0 : throw 1
=> [
    emit Transfer(msg::sender, to, amount);
]

Balances update in the member transforms; emit (and any ~>) runs after storage is consistent. Prefer this shape whenever you do not need a captured return value mid-route.

When phases are the right tool

NeedPattern
Read a remote view, then gate local writesfetch: with var x = … ~> dest, then act where …
Optimistic transfer, then callback, then checkThree phases (see Uniswap V2 swap)
Effect between two groups of transformsExplicit empty phases (tag: []) so transforms can attach

A phase that only applies transforms still appears in the route body as tag: []. The route body is the source of truth for phase names and order; transforms may only reference tags declared there.

Declaration rules

  1. Phases and their order live exclusively in the route body (tag: […]).
  2. In a phased route, every participating transform must carry a phase tag.
  3. In an unphased route, transforms must not carry phase tags.
  4. Untagged actions are illegal inside a phased body.
SituationError
Transform tag missing from the routeunknown phase
Phased route, transform without a tagtransform must specify a phase
Unphased route, transform with a tagroute has no phases
Untagged action in a phased bodyuntagged action

Cross-phase where / var rules are covered in Execution Model.

Phased Execution Model

Given phases P₁, P₂, …, Pₙ in declaration order, each phase runs three steps before the next begins:

for each phase Pᵢ:
    1. Compute transforms tagged Pᵢ
       (all expressions see state_{Pᵢ₋₁} — the pre-phase snapshot)
    2. Apply those transforms atomically → state_Pᵢ
    3. Execute effects of Pᵢ
       (effects see post-transform state_Pᵢ)

Members without a transform at Pᵢ keep their value from Pᵢ₋₁.

Atomicity inside a phase

Transforms in the same phase are computed from the same pre-phase snapshot and written together. Two members can swap without observing a half-updated state:

m_a: u64 { in example() => p1: m_b }
m_b: u64 { in example() => p1: m_a }

After p1, m_a and m_b have exchanged their previous values.

var capture

A phase action may bind the return value of a synchronous external call:

fetch: [
    var bal = balanceOf(who) ~> m_token;
]

The var is in scope from the moment its ~> runs through all later phases of the same invocation. Later transforms and per-phase where clauses may read it; earlier phases and the route-level where may not.

Per-phase where (V27 / V28)

Attach a precondition to a later phase with:

phaseName where (cond) : throw N : [ … ]

The condition lowers to a require(...) at the top of that phase — after previous phases have finished (so earlier vars are bound) and before this phase’s transforms or effects.

withdraw(who: address) => [
    fetch: [
        var bal = balanceOf(who) ~> m_token;
    ]
    act where bal > 0 : throw 401 : [
    ]
]

Scoping rules:

ClauseMay reference vars from…Error if violated
Route-level whereNever (params + pre-existing state only)V27
Per-phase where on PᵢStrictly earlier phases P₁…Pᵢ₋₁V28

Move checks that depend on a captured value onto the phase that needs them — do not put them on the route-level where.

Route-level guards still run first

A route-level where runs once, before any phase. If it fails, no phase executes and no transforms apply. Use it for conditions over parameters and members that already exist at entry.

Reentrancy across phase boundaries (EVM)

Unphased routes keep CEI: all SSTOREs before any CALL.

Phased routes intentionally allow CALLs between groups of storage writes. That is useful (cross-entity reads, flash callbacks) but means a callee can re-enter before later phases run:

P₁ transforms → P₁ effects (external call) → P₂ transforms → …

On reentry, later phases of the outer invocation have not applied yet. Design accordingly:

  • Prefer unphased routes when you only need CEI.
  • When using phases, put critical authorization and balance updates in phases that complete before the external call, or gate later phases with per-phase where on captured post-call state (as Uniswap V2 swap’s check: phase does for the K-invariant).

See Phased Routes for when to introduce phases at all.

Temporal References in Phases

Inside a member transform, bare m_x is the pre-transform value and ^m_x is the post-transform value of another member. In a phased route those meanings are scoped to the current phase, not the whole route.

ReferenceMeaning in phase Pᵢ
m_aValue immediately before Pᵢ’s transforms (already includes earlier phases)
^m_aValue immediately after Pᵢ’s transforms

Using ^member outside a transform body (where, from, route actions, defaults) is a V43 error.

Cross-phase bare names

Pre-phase already incorporates every earlier write. If P₁ updated m_x, then inside a P₂ transform bare m_x is the value from P₁:

entity Counter {
    routes {
        step() => [
            first: []
            second: []
        ]
    }
    m_x: u64 { in step() =>
        first: m_x + 1
    }
    m_y: u64 { in step() =>
        second: m_x + 10
    }
}

Starting from m_x = 0, m_y = 0:

PhaseExpressionm_x seenResult
firstm_x + 10m_x becomes 1
secondm_x + 101 (post-first)m_y becomes 11

Same-phase ^

When several members transform in one phase and one needs another’s new value, use ^:

m_a: u64 { in example() => p1: m_b + 1 }
m_b: u64 { in example() => p1: m_a + 1 }
m_c: u64 { in example() => p1: ^m_a + ^m_b }

^m_a and ^m_b are the results of the first two transforms, so m_c receives (m_b + 1) + (m_a + 1). Without ^, both names would still be the pre-phase snapshot.

Visibility summary

Contextm_x^m_x
Unphased transformPre-routePost-route
Phase Pᵢ transformPre-Pᵢ (incl. prior phases)Post-Pᵢ
Effect in phase PᵢPost-PᵢNot available in effects

Effects always see the post-transform state of their own phase; temporal ^ is a transform-only construct.

Identity Members

Identity members are immutable fields fixed at deploy time. Under deterministic_addresses: true they participate in the CREATE2 address of the instance: same identity args ⇒ same address everywhere in the project.

Declaration

entity Locker {
    identity m_id: u64

    routes {
        constructor() => []
        // …
    }
}

An entity may declare several identity members; all contribute to the address, in declaration order:

entity UniswapV2Pair {
    identity m_token0: address
    identity m_token1: address
}

Rules

PropertyOrdinary memberIdentity member
Mutable after deployYes (transforms)No
Default valueAllowedForbidden
TransformsAllowedForbidden
Address arityDoes not countCounts toward Entity.address(…)

Identity values are supplied as constructor / deploy arguments (together with any non-identity init parameters). They are never rewritten by routes.

Address arity

Entity.address(args…) takes exactly as many arguments as the entity has identity members — same count and types, in declaration order:

Locker.address(locker_id)                    // one identity
UniswapV2Pair.address(token0, token1)        // two identities

from Entity(args) in deterministic mode uses the same arity (rule V33). Mismatched arity is a compile-time error (V32 for deploy, V33 for from).

Singletons

An entity with no identity members is a singleton: one predictable address per project, and Entity.address() takes no arguments:

entity UniswapV2Factory {
    // no identity — singleton
    routes {
        constructor(fee_to_setter: address) => []
        createPair(tokenA: address, tokenB: address) => [
            deploy UniswapV2Pair(min_addr(tokenA, tokenB),
                                  max_addr(tokenA, tokenB))
        ]
    }
}

// elsewhere:
UniswapV2Factory.address()

Governor and TimelockController in examples/governor/ are singletons the same way.

When to use identity

Use identity when distinct instances must be content-addressed — token ids, pair keys (token0, token1), shard indices. If the value can change after deployment or should not affect the address, use an ordinary member instead.

See Deterministic Addresses for CREATE2 / CambrianFactory details.

Deterministic Addresses

With deterministic_addresses: true in project.yaml, every entity in the project gets a predictable CREATE2 address derived from its identity arguments. Cross-entity sends, from checks, and deploy all share that same prediction — no hand-rolled factory.

name: det-messaging
target: evm
deterministic_addresses: true
output_dir: build/
sources:
  - det_guardian.cam
  - det_locker.cam

CREATE2 and CambrianFactory

The EVM backend emits a project-local CambrianFactory. Addresses are derived from:

  • the entity’s deploy bytecode (constructor args = identity fields),
  • a per-deployer factory salt of 0,
  • the fixed factory address known at generation time.

Because the factory address itself is deterministic, every entity can compute every other entity’s address from identity fields alone.

Authors never write a custom factory: opting into deterministic_addresses: true is the only configuration required.

Entity.address(args) and addressOf

Prefer the dot form. An addressOf(…) spelling exists as well; both lower to the same CREATE2 expression:

acceptPing() ~> Locker.address(locker_id)

Arity equals the identity-member count (Identity Members). Singletons use Entity.address() with no arguments.

Use Entity.address(…) anywhere you need a predicted address: send destinations, member initializers, from comparisons.

from Entity(args)

In deterministic mode, sender authentication compares msg.sender to the same CREATE2 expression:

acceptPing()
    from Guardian(m_id)
    => []

Here Locker only accepts pings from the Guardian instance whose identity matches this locker’s m_id. Non-deterministic mode restricts from Entity(…) to a single address argument (V33); prefer deterministic mode for multi-entity systems.

deploy Entity(…)

deploy goes through CambrianFactory:

  1. CREATE2-deploy with identity args as constructor arguments (address matches Entity.address(…)), forwarding any attached value.
  2. Call a generated, factory-guarded initialize(…) for non-identity constructor parameters.
spawnLocker(locker_id: u64) => [
    deploy Locker(locker_id)
]

createPair(tokenA: address, tokenB: address) => [
    deploy UniswapV2Pair(min_addr(tokenA, tokenB),
                          max_addr(tokenA, tokenB))
]

Only the factory owner (bootstrap / tests) or addresses already deployed by this factory may call deployX. EOAs cannot occupy identity CREATE2 slots directly; child deploys go through entity routes the factory already knows. initialize() requires msg.sender == _factory and rejects re-initialization.

Summary

ExpressionRole
Entity.address(args)Predict CREATE2 address from identity
addressOf(…)Equivalent spelling; same CREATE2
from Entity(args)msg.sender == that CREATE2 address
deploy Entity(args)Factory CREATE2 + guarded initialize

Keep identity, addressing, authentication, and deployment aligned — one argument list, one address, everywhere.

Type Widening

When an arithmetic or comparison expression mixes numeric types, Cambrian widens both operands to a common type that can represent every value of either operand. No explicit cast is required for ordinary same-sign mixes.

Rules

Rule 1 — Same sign

Result width is max(width); signedness is unchanged:

m_total: u128 + amount: u64    // amount widened to u128
m_x: i32 + m_y: i64            // m_x widened to i64

Rule 2 — Mixed signedness

Result is signed with width = max(2 × unsigned_width, signed_width):

m_count: u32 + delta: i8       // both → i64
m_val: u64 + offset: i64       // both → i128

Doubling the unsigned width keeps the full unsigned range representable in the signed result.

Rule 3 — u128 / U256 mixed with signed

There is no wider signed primitive than i128. Mixing u128 or U256 with any signed type is a compile-time error:

m_a: u128 + d: i8    // ERROR
m_b: U256 + d: i32   // ERROR

Any unsigned type combined with U256 widens to U256. Integer literals adapt automatically: m_balance + 1 with m_balance: U256 treats 1 as U256.

Full widening table

Cell at row A, column B is the result of A op B. Symmetric cells are omitted; ERR means compile-time error.

u8u16u32u64u128U256i8i16i32i64i128
u8u8u16u32u64u128U256i16i16i32i64i128
u16u16u32u64u128U256i32i32i32i64i128
u32u32u64u128U256i64i64i64i64i128
u64u64u128U256i128i128i128i128i128
u128u128U256ERRERRERRERRERR
U256U256ERRERRERRERRERR
i8i8i16i32i64i128
i16i16i32i64i128
i32i32i64i128
i64i64i128
i128i128

Practical guidance

  • Prefer same-sign arithmetic so widening stays obvious.
  • Mixing u64 with signed values yields i128 — fine, but larger than most balance fields; cast deliberately if you need a narrower store.
  • Do not mix u128 / U256 with signed types; cast one side first.
  • Widening applies to checked and wrapping operators alike; overflow checking (or wrap) happens on the result type after widening.

See Checked vs Wrapping Arithmetic.

Checked vs Wrapping Arithmetic

In .cam source, +, -, and * are checked: overflow or underflow is a failure. +%, -%, and *% are wrapping: the result is reduced modulo the type’s range. There is no wrapping division.

The same operators apply in pure fn bodies, route actions, and member transforms. On EVM, checked ops match Solidity 0.8. On Lean, the source meaning is still checked, but the generated model of +/-/* depends on lean.numerics in project.yaml — the default Lean model wraps.

Checked operators

OpFailure
+ - *Overflow / underflow of the result type after widening — EVM Panic(0x11)
/ %Divisor is zero — EVM Panic(0x12)
narrowing as uN / as iNValue does not fit the target — EVM Panic(0x11)

Widening as is a no-op (zero- or sign-extend). Wrap-around is only +% / -% / *%.

pure fn checked_u8_add(a: u8, b: u8) -> u8 {
    a + b   // reverts on EVM if a + b > 255
}

pure fn checked_div(a: U256, b: U256) -> U256 {
    a / b   // reverts if b == 0
}

Use checked ops for balances, supplies, counters, and any path where silent wrap-around would be a bug.

std::math::{divc,divr,divmod,muldiv} also fail on a zero divisor on every target (independent of lean.numerics).

Wrapping operators

pure fn wrapping_u8_add() -> u8 {
    (255 as u8) +% 1    // 0
}

pure fn wrapping_u8_sub() -> u8 {
    (0 as u8) -% 1      // 255
}

pure fn wrapping_u8_mul() -> u8 {
    (255 as u8) *% 2    // 254
}

Signed wrapping is two’s complement:

pure fn wrapping_i8_inc() -> i8 {
    (127 as i8) +% 1    // -128
}

There is no /% or %%.

EVM lowering

On --target evm, wrapping ops lower to per-entity internal pure helpers _wadd / _wsub / _wmul whose bodies are unchecked { … }. That preserves Solidity 0.8 wrap-around without turning off checked arithmetic elsewhere in the contract. Helpers are emitted only when the entity uses a wrapping op.

The helpers take uint256; a narrower result is truncated with an explicit Solidity cast (so u8 wrap-around is the low 8 bits).

Canonical use: the Uniswap V2 cumulative-price oracle (examples/uniswap-v2/UniswapV2Pair.cam) accumulates time_elapsed * uqdiv(…) with +% so UQ112x112 math can overflow the 112-bit boundary by design.

Lean: lean.numerics

Source +/-/* stay checked as above. How --target lean models those operators is selected in project.yaml. Wrapping ops +%/-%/*% are wrapping infix in every BitVec mode.

lean.numericsCarrier+ - * in Lean+% -% *%
(absent), overflow-wrap, or legacy bitvecBitVec nwrapping infix (not EVM panics)wrapping infix
overflow-panicBitVec nCambrian.checkedAdd / checkedSub / checkedMul (signed: checkedS*) → fail ThrowCode 0x11wrapping infix
natNat / Inttotal / saturating proof arithmetic — overflow ignoreddegrades to Nat/Int +
lean:
  numerics: overflow-panic   # or overflow-wrap (default) or nat

nat is a simplified proof model, not a reproduction of Solidity 0.8: unsigned - saturates (0 - 1 = 0), +/* overflow is ignored, and / 0 is Lean’s total Nat division (not Panic(0x12)). Use overflow-panic when the Lean model must match checked EVM arithmetic. Unknown values are rejected (F2).

See project.yaml and Lean-EVM.

Applicability

TypeChecked + - *Wrapping +% -% *%
u8u128, i8i128yesyes
U256yesyes

Interaction with widening

Overflow checking (or wrapping) applies to the result type after widening. Adding two u8 values into a u16 context widens first, so checked + cannot overflow for any pair of u8s. An explicit wider cast is an alternative to wrapping when you want the full mathematical result.

Mixing operators

Each operator decides independently:

pure fn mix_wrap_then_checked(a: u8, b: u8, c: u8) -> u8 {
    (a +% b) * c    // wrap the sum, then checked multiply
}

Choosing

PreferWhen
+ - *Default on EVM — balances, tallies, supply
+% -% *%Spec requires modular math (TWAP accumulators, hash-like folds)
lean.numerics: overflow-panicLean theorems should treat overflow as a fail, matching EVM

Targets Overview

The Cambrian transpiler is a domain × language matrix. A domain fixes the execution semantics (what a send, address, or balance means). A language is the carrier syntax the emitter writes. Each public CLI flag selects one supported cell of that matrix.

cambrian-transpiler <input.cam|project.yaml> -o <dir> --target evm|lean

The default target is evm. In project.yaml, set target: evm or target: lean. Unknown names are rejected — there is no silent fallback to a default.

Domain × language

DomainMeaningLanguagesCLI targets
EVMSynchronous call / CREATE2 / ETH-balance modelSolidity, Leanevm, lean

Same-domain cores share semantics. A .cam program that is valid for Domain EVM should behave the same under --target evm and --target lean (modulo Lean-only validation such as L5–L14).

On the EVM domain, evm::* intrinsics and E-family rules apply. Lean keeps property blocks as theorems; --target evm expands them into concrete tests and fuzz cases. Multi-entity invariants are accepted on both public targets.

Informal names vs CLI vs output

Informal nameCLI / project.yamlDomainLanguageTypical output
Solidity@ETH / Solidity-EVM--target evm (default)EVMSolidityFlat src/*.sol (pragma solidity ^0.8.24), Foundry tests, foundry.toml
Lean-EVM--target leanEVMLean 4Lake project: Cambrian/Generated/*.lean, *Spec.lean, World.lean, lakefile.toml
# EVM Solidity + Foundry
cambrian-transpiler contracts/counter.cam -o /tmp/out-evm --target evm

# Lean 4 model of the same EVM domain
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean --check-lean

--check-lean runs lake build in the output directory after a Lean emit; on other targets it only warns.

Capability matrix

Capabilityevmlean
Models EVM (evm::*, CREATE2, payable, …)yesyes
Desugars propertytest / fuzzyesno (keeps theorems)
Multi-entity invariantsyesyes
Primary test oracleforge testlake build

Choosing a target

  • Ship on Ethereumevm (Foundry / solc via forge).
  • Prove EVM-domain propertieslean (same semantics, theorem statements; main proofs may be sorry).

Active conformance policy for new language features prioritises Solidity@ETH, then Lean. Details live in the language repo’s docs/TESTING_TARGETS.md.

Chapter map

ChapterContents
Solidity-EVMFlat Solidity, Foundry, CambrianFactory, payable, events/errors
Lean-EVMLake layout, World/*Spec, sorry policy, L-rules

Solidity-EVM

--target evm lowers a Cambrian program to flat Solidity under src/, with an optional Foundry test harness. The pragma is ^0.8.24. Semantics follow the EVM domain: synchronous calls, ETH balances, and (when enabled) CREATE2 addresses.

Output layout

Single file:

out/
  src/Counter.sol          # entity contract
  test/…                   # Foundry tests (if .cam has test/property/invariant)
  foundry.toml
  setup.sh

Multi-file project (project.yaml):

out/
  src/_<project>_project.sol   # combined program
  src/<Entity>.sol             # thin import stubs per entity
  test/…
  foundry.toml
cambrian-transpiler project.yaml -o /tmp/out-evm
cd /tmp/out-evm && forge test

Never invoke solc directly for Cambrian projects — use forge build / forge test.

Foundry configuration

The emitter writes foundry.toml with [profile.default] plus tiered profiles:

ProfileRole
defaultFull local runs (fuzz / invariant knobs from project.yaml)
cambrianCheap CI (fuzz.runs ≈ 100, invariant.runs ≈ 50)
cambrian_nightOvernight stress (thousands of runs / deep traces)

Override via foundry: and top-level fuzz: / invariant: blocks in project.yaml. Run with FOUNDRY_PROFILE=cambrian forge test.

Deterministic addresses and CambrianFactory

With deterministic_addresses: true in project.yaml:

  • The backend emits a CambrianFactory (and ICambrianFactory) that deploys via CREATE2.
  • Entity constructors split into a deploy-time stub plus a factory-guarded initialize().
  • Entity.address(args) / addressOf<Entity>(...) lower to the same CREATE2 prediction used in sends and from clauses.

Singletons (no identity members) get a fixed address; Entity.address() takes no arguments.

Without deterministic mode, from Entity(addr) expects a single address argument (rule V33).

Payable auto-detection

There is no Cambrian payable keyword. Solidity payable is inferred:

  • Any route whose body (or reachable AST) reads msg::value becomes payable.
  • receive is always payable; fallback is payable only when it reads msg::value.
  • Constructors are emitted payable so deploy … with { value: … } works.

Events and errors (ABI)

Cambrian event / error declarations become Solidity ABI items:

event Transfer(from: address indexed, to: address indexed, amount: U256)
error InsufficientBalance(have: U256, need: U256)

routes {
    withdraw(amount: U256)
        where (m_balance >= amount) : throw InsufficientBalance(m_balance, amount)
    => [
        emit Transfer(msg::sender, msg::sender, amount)
    ]
}
  • emit Name(args)emit Name(args);
  • throw Name(args)revert Name(args);
  • At most three indexed event parameters (V36).
  • Unphased routes keep SSTORE-before-CALL ordering (checks-effects-interactions).

Project knobs (EVM)

name: my-dapp
target: evm
deterministic_addresses: true
foundry:
  solc_version: "0.8.24"
  # remappings, fuzz_runs, profiles, …
fuzz:
  runs: 256
invariant:
  runs: 256
  depth: 50

See project.yaml and Testing backends.

Lean-EVM

--target lean emits a Lean 4 + Lake project that models the same EVM domain as --target evm: synchronous calls, CREATE2-style addresses, balances, and world-threaded state. Each property becomes a theorem over the full parameter space rather than a Foundry-style fuzz test.

Lake project layout

out-lean/
  lakefile.toml
  lean-toolchain
  Cambrian.lean
  Cambrian/
    SimpAttrs.lean          # vendored prelude pieces
    …                       # Cambrian.Prelude modules
    Generated/
      World.lean            # World / BlockEnv / call model
      Pure.lean             # pure fns (when present)
      Extern.lean           # extern entity axioms (when present)
      Dispatch.lean         # multi-entity dispatch (when needed)
      Counter.lean          # entity state + helpers
      CounterRoutes.lean    # route transitions
      CounterSpec.lean      # tests / properties / invariants as theorems
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean
cd /tmp/out-lean && lake build

# Or shell out after emit:
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean --check-lean

--check-lean only runs lake build when the target is lean; other targets print a warning.

EVM-domain semantics (high level)

The Lean model mirrors EVM behaviour that the Solidity backend implements:

  • World state threads through routes (WorldState / balances / block env).
  • Typed sends and self-calls update the world; raw value transfers fail closed when underfunded.
  • Deterministic addresses use the same CREATE2 helpers as Entity.address(...).
  • Init / factory-style installation projects constructor args into State like EVM initialize.
  • Multi-entity invariants lower to Action + step + runTrace in the owner entity’s *Spec.lean.

Properties as theorems

Lean keeps each property as a theorem over the full parameter space (it does not expand properties into separate fuzz declarations the way Foundry does). For example:

property "increment is monotonic" (amount: u64) for Counter with { m_count: 0 } {
    assume amount % 2 == 0
    call increment(amount)
    expect state { m_count: amount }

    fuzz "small" { amount in 0..1000 }
}

becomes a -quantified theorem with only assume hypotheses — the fuzz sampling range never weakens the statement. Nested test / fuzz instances still drive Foundry; Lean ignores those ranges when stating the theorem.

Forall markers (m_count: *, ctx { msg::sender: * }) become -bound variables seeding the initial world / MsgCtx.

Sorry policy

Cambrian generates theorem statements; this release does not discharge main goals.

PlacementPolicy
Main theorems / lemmas in *Spec.leansorry in the proof body is expected
defs, terms, non-proof contextsNo sorry — fail closed
Small helper lemmasMay carry real, often auto-generated proofs

lean: { proof_helpers: false } in project.yaml forces statement-only := by sorry on main theorems.

L-rules (Lean / Lean-EVM pair)

High-level classes (full list in the validation reference):

CodesTheme
L1–L2Collection / iterator lowering limits (most Vec / HashMap / folds are supported)
L5–L6Vacuous specs: expect throw on a total route; expect return without -> T
L7skip from not yet honoured on Lean (warning)
L8Typed send dest must resolve to an in-program Entity.address / addressOf
L9 / L11Capturing or fire-and-forget self-call to a failing route without a fail surface
L10Invariant action contains a send (atomic step elides interleavings) — warning
L12TVM-only rescue / recover (async bounce recovery) is dropped on Lean — warning
L13–L14Missing Lean lowering for some stdlib / unresolved types

Forced codegen for L9/L11 emits a non-compiling -- L9: / -- L11: sentinel rather than silently omitting the check.

When to use Lean

Use Lean when you want machine-checked statements of the EVM model next to Foundry oracles — not as a substitute for forge test. Keep the same .cam fixtures across Domain EVM cores wherever Lean accepts the surface.

Testing Overview

Cambrian embeds a test language in .cam sources. The same declarations lower to Foundry tests or Lean theorems depending on the target — you do not maintain a separate Solidity or Lean test suite by hand.

Write tests next to the entity they exercise (same file or a sibling *.test.cam merged via project.yaml). Validation rules T* / I* catch arity, context, and forall mistakes before codegen.

The triad

ConstructRoleTypical lowering
testConcrete, deterministic scenarioFoundry test_*, Lean theorem with fixed values
propertyParameterised statement + nested test / fuzz instancesLean: one theorem. EVM: concrete tests and fuzz cases
invariantStateful random call sequences + checksFoundry invariant handler, Lean runTrace
entity Counter {
    routes {
        increment(amount: u64) => []
        reset() => []
        getCount() -> u64 => [ return(m_count) ]
    }
    m_count: u64 {
        in increment(amount) => m_count + amount
        in reset() => 0
    }
}

test "starts at zero" for Counter with { m_count: 0 } {
    call getCount()
    expect return 0
}

property "reset zeroes" for Counter with { m_count: * } {
    call reset()
    expect state { m_count: 0 }
    fuzz { }
}

invariant "count stays non-negative" for Counter {
    init { m_count: 0 }
    action increment(amount: u64) { bound amount in 0..1000 }
    action reset() { }
    check m_count >= 0
}

How they relate

  • A test answers “does this exact scenario pass?”
  • A property answers “does this hold for a family of inputs?” — Lean keeps the family; fuzz backends sample it.
  • An invariant answers “does this hold after arbitrary sequences of allowed actions?”

Prefer properties for single-step algebraic facts; reach for invariants when order and interleaving matter.

Backend map (public targets)

BackendHow you run itDriven by
Foundryforge test after --target evmtest / property instances / invariant
Lean Speclake build / --check-leanRaw property + test + invariant theorems

See Backend matrix for the feature grid (multi-entity invariants, trace::*, fuzz types, …).

Design rules worth remembering

  • Entity state vs context: pins and forall markers for members live in with / init; msg:: / sys:: live in ctx { … } (T22).
  • assume vs bound: logical preconditions vs sampling ranges — only assume reaches Lean.
  • Desugar: --target evm lowers property before codegen; Lean keeps properties intact for clean theorems.
  • Expects stack: after each call, you may assert state, throw, return, and (where supported) effects. Lean conjoins every expect on that call.
  • Shared fixtures: Domain EVM cores (evm, lean) should reuse the same .cam matrices where the surface is accepted.

Project-level knobs

fuzz:
  runs: 256
  seed: 0
invariant:
  runs: 256
  depth: 50
  fail_on_revert: false

These feed Foundry profiles. Per-decl attributes (#[runs], #[depth], #[fail_on_revert]) override globals where the backend honours them.

Chapter map

ChapterContents
Unit teststest syntax, lenses, multi-step, skip from, with/ctx
PropertiesNested instances, assume/bound, forall *
InvariantsActions/checks, track/derived, trace::*, multi-entity, attributes
BackendsFoundry and Lean — support matrix

Unit Tests

A test declares a concrete scenario against one entity: optional initial state, message/system setup, one or more calls, and expect* assertions.

Syntax

test "fund changes state" for Escrow {
    let buyer = 0x0000000000000000000000000000000000000001
    let seller = 0x0000000000000000000000000000000000000002
    let arbiter = 0x0000000000000000000000000000000000000003
    call constructor(buyer, seller, arbiter, 1000, 9999)
    expect state { m_state: 0 }

    msg { sender: buyer }
    call fund()
    expect state { m_state: 1, m_funded: true }
}

Modifier order when skipping sender checks:

test "…" for <Entity> skip from with { … } { … }

skip from sits between the entity name and the optional with block.

Building blocks

ElementMeaning
test "name" for EntityDeclaration; entity must exist
skip fromDisable all from-clause sender checks in this test
with { field: value, … }Initial entity state (members only)
ctx { msg::x: …, sys::y: … }Preferred place for blockchain context on properties/invariants; plain tests often still use inline msg { } / sys { } blocks
let name = exprBinding visible for the rest of the test
msg { … } / sys { … }Per-step message / system context overrides
call route(args)Invoke a route (arity must match)
expect state { … }Assert members after a call
expect throw N / expect throw Name(…)Assert revert / error
expect return EAssert return payload (route must declare -> T)
expect effects […]Assert outgoing effects (backend-dependent; prefer state/return on EVM)

On Lean, every expect after a call is kept and conjoined in the theorem goal.

Lenses

Nested fields, map keys, and tuple slots use path syntax:

expect state { m_pools[0].reserve_a: 1500 }
expect state { m_allowances[owner][spender]: 500 }
expect state { m_pair.0: 10 }

expect return.0 == 0
expect return.len == 3
expect return[0].name == 42

Enums / options:

expect state { m_state: State::Funded }
expect state { m_resolution: some("resolved") }

HashMap literals:

expect state { m_balances: { alice => 900, bob => 100 } }
expect state { m_balances[alice]: 900 }

Multi-step tests

State threads automatically: output of one call is input to the next, with or without an intervening expect.

test "multi-step" for Counter with { m_count: 0 } {
    call increment(1)
    expect state { m_count: 1 }

    call increment(1)
    expect state { m_count: 2 }
}

skip from vs authentic senders

Routes with from Entity(…) verify msg.sender. For unit tests of business logic, skip from disables that check. For end-to-end sender checks on EVM, set msg { sender: … } to the address the from clause expects (often Entity.address(…) under deterministic mode).

with vs ctx (brief)

  • with / state pins — entity members (m_count: 0).
  • ctxmsg::sender, msg::value, sys::now, sys::balance, …. Putting msg:: inside with is rejected (T22).

Plain test blocks historically also use msg { } / sys { } bodies; properties and invariants prefer the explicit ctx { } record. See Properties.

Properties and Fuzz

A property is Cambrian’s abstract, parameterised statement about an entity. It is the canonical construct for property-based testing and for Lean specifications.

Shape

  1. Typed parameters attach to the property name (before for).
  2. The body holds logical steps only: msg/sys or ctx, let, assume, call, expect*.
  3. Concrete test and randomised fuzz instances nest inside the property.
property "increment is monotonic" (amount: u64) for Counter with { m_count: 0 } {
    assume amount % 2 == 0
    call increment(amount)
    expect state { m_count: amount }

    test "even-4" { amount: 4 }
    fuzz "small" { amount in 0..1000 }
    #[runs(2000)] fuzz "wide" { amount in 0..=1000 } with { m_count: 5 }
}

Notes:

  • Write for Counter, never for Counter(amount: u64).
  • Sampling bounds belong in fuzz instances, not the property body (T11).
  • Instance forms: test { p: value, … } (every param fixed) and fuzz { p in lo..hi, … } (ranges; omitted params use the full type range).
  • Property-level with is the default initial state; instances may override with their own with.
  • Instance attributes include #[runs(N)] (fuzz), #[tag("…")], #[skip_from].
  • A property with no instances still emits output: full-range fuzz if it has params, or a deterministic test if not. Lean always emits one theorem per property.

assume vs bound

ConstructRoleWhereReaches Lean?
assume <bool>Logical preconditionproperty bodyyes ( hypothesis)
bound x in lo..hiSampling rangefuzz instanceno
bound x in lo..=hiInclusive rangefuzz instanceno

Lean quantifies over the full parameter type with only genuine assumes — fuzz ranges never weaken the theorem.

Forall marker *

property "reset zeroes from any start" for Counter with { m_count: * } {
    call reset()
    expect state { m_count: 0 }
}

property "holds for any state" for Counter with { * } {
    call reset()
    expect state { m_count: 0 }
}
Form in withMeaning
field: vpin member
field: *forall over member
* (bare)forall over all members (explicit pins still win)

with vs ctx

Entity state and call context are separate records:

property "incr from any sender" (amount: u64) for Counter
    with { m_count: 0 }
    ctx { msg::sender: * } {
    call increment(amount)
    expect state { m_count: amount }
    fuzz { amount in 0..10 }
}
ctx formMeaning
msg::x: v / sys::x: vpin
msg::x: * / sys::x: *forall / sample

Allowed context names: msg::{sender,value}, sys::{now,timestamp,chainid,block_number,balance} (T20). Context markers inside withT22.

Per-target:

TargetForall state / ctx becomes…
Lean-bound seeding world / MsgCtx
fuzz (EVM)Extra sampled input
concrete testState forall falls back to default (W8); ctx forall dropped

How Foundry runs properties

On --target evm, each nested instance becomes a runnable harness:

  • fuzz instance → fuzz case with bound p in lo..hi, then the logical body.
  • test instance → concrete test with let p = value; assumes are dropped (values are presumed valid).

Lean keeps the property as a single theorem instead (see Lean-EVM).

How preconditions lower:

Backendassumebound
Foundryvm.assumeStdUtils.bound

Unsupported fuzz parameter types warn with W7 rather than failing the build.

Project-wide fuzz knobs

fuzz:
  runs: 256
  seed: 0
  shrink: true
  max_local_rejects: 1024

Threaded into Foundry [profile.default.fuzz].

Invariants

Stateful invariants fuzz sequences of actions: the harness picks from a declared action pool, applies each call, and re-checks boolean predicates after every successful step. Use them for state-machine bugs that single-call properties miss.

Single-entity form

invariant "count never exceeds bound" for Counter {
    init { m_count: 0 }

    senders { 0xAA00000000000000000000000000000000000001,
              0xBB00000000000000000000000000000000000002 }

    action increment(amount: u64) {
        bound amount in 0..1000
    }
    action reset() { }

    check m_count <= 1_000_000_000
}

invariant "reset must succeed" for Counter
    #[fail_on_revert]
{
    action increment(amount: u64) { bound amount in 0..100 }
    action reset() { }
    check m_count >= 0
}
ConstructMeaning
init { … } / with { … }Initial entity state; supports * forall (sampled on Foundry; on Lean). Identity members may be set here for address resolution
ctx { … }Message / system context for the trace
senders { … }Optional sender pool (round-robin); default is a zero sender
action route(params) { bound|assume|skip if }Callable routes; body may only hold preconditions / advanceTime
check <bool>Conjoined after every successful call
#[fail_on_revert]Any revert fails the trace (default: skip reverting steps)

Action gates: assume vs skip if

KeywordWhen false
assume <expr>Step excluded (vm.assume; Lean adds traceValid … →)
skip if <expr>Step is a no-op (counts toward length, state unchanged)
bound v in lo..hiReject out-of-range parameter samples

An invariant with no assume emits no stepValid/traceValid machinery on Lean (byte-identical to older output).

trace::*

Inside assume / check only (I15/I16):

AccessorMeaning
trace::lengthActions before this point (assume) or full length (check)
trace::count(route)How often route ran (must name a declared action)
trace::lastWas(route)Previous action was route
invariant "deposits lead withdrawals" for Vault {
    init { m_balance: 0 }
    action deposit(amount: u64) {
        bound amount in 1..100
        assume trace::length < 10
    }
    action withdraw(amount: u64) {
        bound amount in 1..100
        assume m_balance >= amount
        assume !trace::lastWas(withdraw)
        assume trace::count(withdraw) <= trace::count(deposit)
    }
    check m_balance >= 0
    check trace::count(deposit) >= trace::count(withdraw)
}

Accumulators are emitted only when referenced (Lean TraceAcc; Foundry handler counters).

track / derived / excludes (Foundry-rich)

invariant "borrowed never exceeds total assets" for LendingPair
    #[tag("INV-LEND-001")]
    #[runs(2000)]
    #[depth(80)]
    #[with_time]
{
    init { m_total_assets: 1000, m_borrowed: 0 }

    track {
        let initial_total = m_total_assets
    }

    derived utilization() -> u128 {
        return (m_borrowed * 100) / m_total_assets
    }

    exclude senders { 0x0000000000000000000000000000000000000001 }
    exclude selectors { accrueInterest }

    action deposit(amount: u128) { bound amount in 1..m_total_assets }
    action withdraw(amount: u128) {
        skip if m_total_assets == 0
        bound amount in 1..m_total_assets
    }
    action accrueInterest() { advanceTime(3600) }

    check m_borrowed <= m_total_assets
}
Attribute / blockEffect
#[runs(N)] / #[depth(N)]Per-decl Foundry forge-config invariant runs/depth
#[with_time]Synthetic advanceTime(secs) in the selector set (vm.warp + vm.roll)
#[fail_on_revert]Fail traces on any revert
#[tag("…")]NatSpec / function-name suffix for audit trails
track { let … }Snapshot fields captured once in the handler
derived name(…) -> TPure view helper on the handler
exclude senders / exclude selectorsFoundry excludeSender / excludeSelector

track / derived / excludes are richest on Foundry; other backends may ignore some of these constructs at codegen time.

Multi-entity systems

invariant "vault sum covers treasury" for { v: Vault, a: Vault, t: Treasury } {
    init v { m_balance: 0 }
    init a { m_balance: 0 }
    init t { m_total: 0 }

    action v.deposit(amount: u64) { bound amount in 0..1000 }
    action a.deposit(amount: u64) { bound amount in 0..1000 }
    action t.credit(amount: u64) { bound amount in 0..1000 }

    check v.m_balance + a.m_balance >= t.m_total
}

Actions and member refs must be qualified (I9/I11). Supported on Foundry and Lean.

Project defaults

invariant:
  runs: 256
  depth: 50
  fail_on_revert: false
  seed: 0
  max_local_rejects: 1024

Per-invariant attributes override the global fail_on_revert / runs / depth where applicable.

Testing Backends

Public targets drive several oracles from the same .cam fixtures. This chapter summarises how each backend runs and what it supports.

Foundry (forge test)

Target: --target evm

  • Emits test/*.t.sol plus foundry.toml (and setup.sh).
  • Unit test → Solidity test_* functions with vm.prank / storage setup.
  • Desugared property fuzz → function testFuzz_* with vm.assume / bound.
  • invariantHandler_* + Invariant_*Test with targetContract / targetSelector / targetSender.
  • Profiles: default, cambrian (CI), cambrian_night (stress).
cambrian-transpiler project.yaml -o /tmp/out && cd /tmp/out
forge test
FOUNDRY_PROFILE=cambrian forge test

Lean Spec theorems

Target: --target lean (+ optional --check-lean)

  • test / property / invariant → theorems in Cambrian/Generated/<E>Spec.lean.
  • Each property becomes a theorem; fuzz sampling ranges never appear in the statement.
  • Main proofs may be sorry; model defs must not contain sorry.
  • lake build type-checks the project; discharging specs is a separate effort.

Cross-target support matrix

FeatureFoundryLean
Concrete testyestheorem
property + nested testyestheorem
property + nested fuzzyestheorem (no bounds)
Single-entity invariantyesrunTrace
Multi-entity invariantyesyes
trace::*yesTraceAcc
#[with_time] / track / derivedyes (rich)modelled where applicable
skip fromyeswarn L7

Fuzz parameter types

TypeFoundry
u8u128, i8i128, u256, bool, addressyes
bytes32, Stringyes

Unsupported combinations warn (W7) instead of hard-failing.

Practical workflow

  1. Write fixtures once under contracts/ or your project sources.
  2. Gate on Foundry (forge test) for EVM behaviour.
  3. Add Lean emit (+ lake build when available) for Domain EVM theorem statements.

Counter

The smallest useful Cambrian entity: one member, three routes. Source of truth: contracts/counter.cam in the cambrian-lang repo.

Entity

entity Counter {

    routes {
        increment(amount: u64) => []
        reset() => []
        getCount() -> u64 => [
            return(m_count)
        ]
    }

    m_count: u64 {
        in increment(amount) => m_count + amount
        in reset() => 0
    }
}
  • increment / reset mutate state only — empty action lists.
  • getCount returns the current value; it has no member transform, so m_count is unchanged.
  • Transforms live next to the member they update, not inside the route body.

Companion fixtures

Same directory ships ready-made harnesses (Foundry / Lean pick them up when listed in a project or passed beside the entity):

FileRole
contracts/counter.test.camUnit tests: increment, reset, expect return, multi-step
contracts/counter.fuzz.camProperties + fuzz { amount in … } ranges; forall m_count: *
contracts/counter.invariant.camInvariants over increment / reset, incl. ctx { … } forall

Excerpt from the unit suite:

test "increment adds to count" for Counter with { m_count: 5 } {
    call increment(3)
    expect state { m_count: 8 }
}

test "getCount returns current" for Counter with { m_count: 99 } {
    call getCount()
    expect return 99
}

Property / fuzz:

property "increment from zero" (amount: u64) for Counter with { m_count: 0 } {
    call increment(amount)
    expect state { m_count: amount }

    fuzz { amount in 0..1000 }
}

Try it

cambrian-transpiler contracts/counter.cam -o /tmp/counter --target evm
# or Lean:
cambrian-transpiler contracts/counter.cam -o /tmp/counter-lean --target lean

See Unit Tests, Properties and Fuzz, and Invariants for the testing surface these companions exercise.

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 {
    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 Unauthorized();
    error ZeroAmount();

    routes {
        constructor(name: String, symbol: String, decimals: u8,
                    initial_supply: U256) => []

        transfer(to: address, amount: U256)
            where amount > 0 : throw ZeroAmount() => [
                if 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)
        ]
    }
    // members below…
}

where / throw Name(…) pair with declared errors; emit pairs with declared events. Up to three indexed fields per event on EVM (V36).

HashMap balances

m_balances: HashMap<address, U256> {
    in constructor(_, _, _, initial_supply) => {
        let sender = msg::sender;
        {}.insert(sender, 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);
        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: {}.

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.camBaseline entity (numeric throws, no events)
contracts/erc20_events_evm.camEvent / emit patterns
contracts/erc20_errors_evm.camNamed errors

Add these paths to a project.yaml with target: evm to generate Foundry harnesses — same workflow as Counter.

Escrow

Two-party escrow with an arbiter and a deadline. Prefer the enum-based contracts/escrow_v2.cam; contracts/escrow.cam is the same machine with u8 state codes.

State machine

Created  --fund()-->          Funded
Funded   --release()-->       Released   (pay seller)
Funded   --refund()-->        Refunded   (pay buyer)
Funded   --claimTimeout()-->  Refunded   (after deadline)
Funded   --dispute()-->       Disputed
Disputed --resolve(pct)-->    Resolved   (split by %)

Enum version (excerpt)

type Amount = U256
type Timestamp = u64

pure fn is_party(sender: address, buyer: address, seller: address) -> bool {
    sender == buyer || sender == seller
}

entity EscrowV2 {

    enum State {
        Created,
        Funded,
        Released,
        Refunded,
        Disputed,
        Resolved
    }

    routes {
        constructor(buyer: address, seller: address, arbiter: address,
                    amount: Amount, deadline: Timestamp) => []

        fund()
            where m_state == State::Created : throw 10 &&
                  msg::sender == m_buyer : throw 11 => []

        release()
            where m_state == State::Funded : throw 10 &&
                  msg::sender == m_buyer : throw 11 => [
                ~> m_seller
            ]

        refund()
            where m_state == State::Funded : throw 10 &&
                  msg::sender == m_seller : throw 12 => [
                ~> m_buyer
            ]

        claimTimeout()
            where m_state == State::Funded : throw 10 &&
                  msg::sender == m_buyer : throw 11 &&
                  msg::timestamp > m_deadline : throw 16 => [
                ~> m_buyer
            ]

        dispute()
            where m_state == State::Funded : throw 10 &&
                  is_party(msg::sender, m_buyer, m_seller) : throw 14 => []

        resolve(to_buyer_pct: u8)
            where m_state == State::Disputed : throw 10 &&
                  msg::sender == m_arbiter : throw 13 => [
                let buyer_amount = m_amount * to_buyer_pct as Amount / 100;
                let seller_amount = m_amount - buyer_amount;
                if buyer_amount + seller_amount == m_amount => [
                    ~> m_buyer
                    ~> m_seller
                ]
            ]

        getStatus() -> (u8, Amount, Timestamp) => [
            let state_code = match m_state {
                State::Created => 0,
                State::Funded => 1,
                State::Released => 2,
                State::Refunded => 3,
                State::Disputed => 4,
                State::Resolved => 5
            };
            return(state_code, m_amount, m_deadline)
        ]
    }

    m_state: State {
        in constructor(_, _, _, _, _) => State::Created
        in fund() => State::Funded
        in release() => State::Released
        in refund() => State::Refunded
        in claimTimeout() => State::Refunded
        in dispute() => State::Disputed
        in resolve(_) => State::Resolved
    }

    m_resolution: Option<String> {
        in constructor(_, _, _, _, _) => none
        in resolve(_) => some("Resolved by arbiter")
    }
}

Buyer / seller / arbiter / amount / deadline members are set once in constructor (full file in the repo). Plain ~> dest transfers value; the arbiter’s resolve splits by percentage with a conservation check.

Why enums

escrow.cam (u8)escrow_v2.cam (enum)
State typemagic numbersState::Funded
Queriesreturn raw u8match to a code if needed
Extra stateOption<String> resolution note

Same guards and payouts; the enum version is what you want for new code. Governor and Timelock use the same enum + member-transform pattern for proposal / operation lifecycles.

Tests in the workspace

FileRole
contracts/escrow_v2.test.camUnit tests for the enum escrow
contracts/escrow.test.camSame machine with u8 state codes
contracts/escrow_two_vaults.invariant.camMulti-entity invariant example

List them in project.yaml with target: evm to emit Foundry tests — see Counter.

Multi-Entity Messaging

A minimal two-entity system with deterministic CREATE2 addresses, deploy, typed sends, and from Entity(args) authentication. Source: contracts/det_guardian.cam, contracts/det_locker.cam, and contracts/det_messaging.yaml.

Project

name: det-messaging
target: evm
deterministic_addresses: true
output_dir: build/
sources:
  - det_guardian.cam
  - det_locker.cam
cambrian-transpiler --project contracts/det_messaging.yaml

Both entities declare identity m_id: u64, so Guardian.address(id) and Locker.address(id) are stable CREATE2 predictions. The auto-generated CambrianFactory deploys them.

Architecture

Guardian(id)                     Locker(id)
+------------------+             +------------------+
| spawnLocker(id)  | --deploy--> | (new instance)   |
| ping(locker_id)  | ----~>----> | acceptPing()     |
|                  |             |   from Guardian(m_id)
+------------------+             +------------------+

Matching identity values: a locker with m_id = 7 only accepts acceptPing from Guardian(7).

Guardian

entity Guardian {
    identity m_id: u64

    routes {
        constructor() => []

        ping(locker_id: u64) => [
            acceptPing() ~> Locker.address(locker_id)
        ]

        spawnLocker(locker_id: u64) => [
            deploy Locker(locker_id)
        ]

        getPings() -> u64 => [
            return(m_pings_sent)
        ]
    }

    m_pings_sent: u64 {
        in constructor() => 0
        in ping(_) => m_pings_sent + 1
    }
}
  • deploy Locker(locker_id) — factory CREATE2; address equals Locker.address(locker_id).
  • acceptPing() ~> Locker.address(…) — typed send to the predicted address (no stored handle required).

Locker

entity Locker {
    identity m_id: u64

    routes {
        constructor() => []

        acceptPing()
            from Guardian(m_id)
            => []

        getPingCount() -> u64 => [
            return(m_ping_count)
        ]
    }

    m_ping_count: u64 {
        in constructor() => 0
        in acceptPing() => m_ping_count + 1
    }
}

from Guardian(m_id) lowers to require(msg.sender == CREATE2(Guardian, m_id)) in deterministic mode — sender type and identity in one clause.

Patterns

PatternExample
Shared identity keySame m_id on both entities
Predict without storingLocker.address(locker_id)
Child deploydeploy Locker(locker_id)
Authenticate peerfrom Guardian(m_id)

Larger systems use the same idioms: Governor holds Address<ERC20Votes> / Address<TimelockController> and Uniswap’s factory does deploy UniswapV2Pair(t0, t1) then records UniswapV2Pair.address(t0, t1). See Governor and Uniswap V2.

Background: Identity Members, Deterministic Addresses, Multi-File Projects.

Governor

Showcase port of OpenZeppelin-style governance (Governor, TimelockController, ERC20Votes) under examples/governor/ in cambrian-lang. Targets EVM with deterministic CREATE2; Lean project file is project.lean.yaml.

Do not expect a line-for-line OZ clone — see the repo README.md / PLAN.md for intentional simplifications (snapshot-free voting, single-target proposals, fixed quorum, …).

Layout

examples/governor/
  project.yaml              # target: evm, deterministic_addresses: true
  project.lean.yaml
  ERC20Votes.cam            (+ .test / .fuzz / .invariant)
  TimelockController.cam    (+ .test / .invariant)
  Governor.cam              (+ .test / .fuzz / .invariant)
  ref/                      # vendored OZ reference
  build/                    # generated (gitignored)

Entities

EntityShapeRole
ERC20VotesToken + votesgetVotes / transfers; feed for castVote
TimelockControllerSingletonSchedule / execute / cancel with role from
GovernorSingletonPropose → vote → queue → execute

Cross-contract handles use typed addresses:

constructor(token:    Address<ERC20Votes>,
            timelock: Address<TimelockController>,
            admin:    address,
            voting_period: u64,
            quorum_votes:  U256) => []

Idioms exercised

Phased var capture — read voting power, then tally only if weight is positive (Phased Routes):

castVote(proposal_id: U256, support: u8) => [
    read: [
        var weight = getVotes(msg::sender) ~> m_token;
    ]
    tally where weight > 0 : throw 100 : [
    ]
]

Role from on members — Timelock gates schedule / execute / cancel:

schedule(...) from m_proposer : throw 1
    where delay >= m_min_delay : throw 2
=> []

execute(...) from m_executor : throw 3 => [
    ~> target with { value: value, data: data }
]

Unphased execute relies on EVM SSTORE-before-CALL so reentry cannot re-run a finished op.

EnumsProposalState / OpState drive member transforms. hashOf(...) — proposal / operation ids → keccak256(abi.encode(...)) on EVM.

Build and test

# From the cambrian-lang repo root:
cargo run -p cambrian-transpiler --release -- \
    --project examples/governor/project.yaml

cd examples/governor/build
bash setup.sh
forge build
forge test -vv

# Lean (separate project file):
cambrian-transpiler --project examples/governor/project.lean.yaml

Full commands and status live in examples/governor/README.md.

Uniswap V2

Showcase port of Uniswap V2 Core (UniswapV2Factory, UniswapV2Pair, test ERC20) under examples/uniswap-v2/ in cambrian-lang. First in-repo exercise of two-field identity CREATE2, deep phased routes with cross-contract var capture, and EIP-2612 permit via evm::.

Layout

examples/uniswap-v2/
  project.yaml              # deterministic_addresses: true, via_ir
  project.lean.yaml
  shared.cam                # imported pure helpers (min/max/mul_div)
  ERC20.cam                 (+ .test / .fuzz / .invariant)
  UniswapV2Pair.cam         (+ .test / .fuzz / .invariant)
  UniswapV2Factory.cam      (+ .test / .invariant)
  ref/                      # upstream V2 + FlashCallback
  build/

Entities

EntityIdentityRole
ERC20m_token_id: u8Multi-instance test token; ERC20.address(0) / address(1)
UniswapV2Pairm_token0, m_token1Reserves, mint / burn / swap, TWAP accumulators
UniswapV2Factory(singleton)deploy UniswapV2Pair(t0, t1), pair registry

Factory create (no hand-rolled CREATE2):

createPair(tokenA: address, tokenB: address)
    where tokenA != tokenB : throw 1
    /* … zero / duplicate guards … */
=> [
    deploy UniswapV2Pair(min_addr(tokenA, tokenB),
                          max_addr(tokenA, tokenB))
]

The deployed address is exactly UniswapV2Pair.address(min(tokenA, tokenB), max(tokenA, tokenB)).

Phased liquidity and swaps

mint / burn / sync / skim capture both token balances in a read: phase, then write reserves or payouts in a later phase. swap is three phases — optimistic transfer, optional flash callback, then K-invariant gate:

swap(amount0_out: U256, amount1_out: U256, to: address, data: CamData)
    where amount0_out > 0 || amount1_out > 0 : throw 200
    /* … reserve bounds … */
=> [
    optimistic: [
        transfer(to, amount0_out) ~> m_token0
        transfer(to, amount1_out) ~> m_token1
    ]
    callback: [
        ~> to with { value: 0, data: data }
        var bal0 = balanceOf(sys::address) ~> m_token0;
        var bal1 = balanceOf(sys::address) ~> m_token1;
    ]
    check where k_holds(bal0, bal1, m_reserve0, m_reserve1,
                        amount0_out, amount1_out) : throw 203 : []
]

Per-phase where on check: sees post-callback bal0 / bal1 (Execution Model).

ERC-20 extras

  • EIP-2612 permitevm::ecrecover + evm::keccak256Packed over an EIP-712 digest; m_nonces bumped in the matching transform (examples/uniswap-v2/ERC20.cam).
  • Wrapping TWAP math+% on cumulative prices (Checked vs Wrapping Arithmetic).
  • Vec push — factory m_all_pairs.push(pair) in a member transform.
  • import "./shared.cam" — shared pures without duplicating entities.

Build and test

cargo run -p cambrian-transpiler --release -- \
    --project examples/uniswap-v2/project.yaml

cd examples/uniswap-v2/build
bash setup.sh
forge build
forge test -vv

foundry.via_ir: true is set in project.yaml for the generated Foundry config. Lean: project.lean.yaml. Details and the closed gap log (G-U1G-U10) are in examples/uniswap-v2/README.md and PLAN.md.

Standard Library Reference

Cross-target helpers live under std::. Bare calls such as min(a, b) or sha256(data) are rejected (V49) — use the qualified forms.

Authoritative tables also live in cambrian-lang docs/STDLIB.md.

std::math

FunctionRole
min, max, minmaxOrdering
abs, signSign
clampRange clamp
muldiv, muldivmodWidening multiply-then-divide
divmod, divc, divrDivision variants
modpow2, powPowers

std::str

FunctionRole
parse_u64 / parse_u8 / parse_i64 / parse_uint / parse_intString → Option<T>
formatFormat string

String ↔ number conversion is never implicit (V50): use parse_* / format.

match std::str::parse_u64(s, 10) {
    some(n) => n,
    none => 0,
}

std::crypto

FunctionRole
sha256Hash bytes / string → digest
NamespaceDocs
msg::Message Context
sys::System Context
evm::EVM Intrinsics

Keywords

Reserved words and reserved route names in public Cambrian programs:

Keyword / nameUse
entityDeclare an entity
externextern entity foreign surface
routes / routeRoute block / extern route stub
view / init / private / acceptRoute modifiers
pureRoute modifier or start of pure fn
receive / fallbackReserved route names on EVM (Receive and Fallback)
where / from / throwPreconditions / sender check / failure
returnReturn from a route
callInvoke a private route from an action list
deployDeploy another entity
emitEmit an event
event / errorDeclarations
library / using / forLibraries, using Lib for T, expression/action for
pure fnTop-level pure function
const / type / record / enumDeclarations
macroEntity macros (@name())
match / if / elseControl flow
let / varBindings (var for capturing ~> returns)
inMember transforms; bounds in bound x in lo..hi
some / noneOption constructors
test / property / fuzz / invariantTesting
expect / assume / bound / check / action / with / ctxTest / invariant vocabulary
import / useFile import (import "./…" or bare import "token/core.cam") / namespace (use …)
indexedEvent parameter indexing (EVM)
identityIdentity member marker
skipTest modifier (skip from) or invariant guard

Operators such as ~>, ^, =>, &&, ||, +% are listed under Operator Precedence.

TVM-only syntax (rescue, recover, gosh::, …) is intentionally omitted from this public reference.

Operator Precedence

Expression operators bind as in cambrian-transpiler/src/cambrian.lalrpop (lowest to highest). All listed binary operators are left-associative.

LevelOperatorsNotes
1||Logical or
2&&Logical and
3|Bitwise or — not route/action syntax
4^Bitwise xor — not temporal ^member
5&Bitwise and
6== !=Equality
7< > <= >=Ordering
8<< >>Shifts
9+ - +% -%Checked / wrapping add & sub
10* / % *%Checked / wrapping mul, div, mod
11as TypeCast (chains left: x as u64 as U256)
12! - *Unary not, negation, dereference
13.field .method() [index]Postfix access
14PrimaryLiterals, ^member, @macro(), msg::, sys::, calls

Not expression operators

These appear in routes, transforms, or actions — they do not participate in the expression precedence chain above:

FormRole
=>Route body, transform, match arm, action-level for
~>Send / transfer action
with { … }Send / deploy options after ~>

Temporal ^member

^m_count is parsed as a primary expression (prefix on a member name). It is unrelated to bitwise xor between two ordinary expressions.

Send options use with { value: … } after ~> dest. See Send Messages.

For the full grammar, see cambrian-transpiler/src/cambrian.lalrpop (ExprOr through ExprPrimary).

Transpiler CLI

cambrian-transpiler [OPTIONS] <INPUT.cam>
cambrian-transpiler --project <project.yaml> [OPTIONS]

Targets

--targetOutput
evm (default)Flat Solidity + Foundry harness
leanLean 4 Lake project

Flags

FlagDescription
-o <dir>Output directory
--project <file>Load a multi-file project.yaml (target, sources, optional library_paths / imports)
--target <name>Select backend
--dump-astParse, print AST, exit
--source-mapEmit source maps
--check-leanAfter Lean codegen, run lake build

Examples

cambrian-transpiler contracts/counter.cam -o /tmp/c-evm --target evm
cambrian-transpiler contracts/counter.cam -o /tmp/c-lean --target lean --check-lean
cambrian-transpiler --project examples/governor/project.yaml
cambrian-transpiler contracts/counter.cam --dump-ast

Build the binary with cargo build --release in the cambrian-lang workspace; the executable is target/release/cambrian-transpiler.

project.yaml Reference

Multi-file projects are declared in YAML and loaded with --project.

name: my-dapp
target: evm          # required: evm | lean
output_dir: build/
sources:
  - Token.cam
  - Vault.cam
  - Vault.test.cam
library_paths:
  - ${CAMBRIAN_STDLIB}     # optional; env-expanded
  - ../vendor/cam-libs
imports:
  - math.cam               # shared helpers, not entities
  - token/erc20-core.cam
deterministic_addresses: true

Top-level keys

KeyTypeNotes
namestringOptional project name
targetstringRequiredevm or lean
output_dirstringDefault build/
sourceslist of pathsEntry-point .cam files (entities, tests)
library_pathslist of pathsExtra directories for shared .cam files. ${VAR} is expanded; then absolute or relative to the YAML. No built-in default.
importslist of pathsShared helper files (not entities). Yaml directory first, then each library_paths entry. Same program-wide names as your own files.
deterministic_addressesboolEVM CREATE2 / factory mode
foundryobjectFoundry / solc settings
fuzzobjectDefault fuzz runs / seed
invariantobjectDefault invariant runs / depth
leanobjectLean emission knobs

Shared files (library_paths / imports)

Leave both keys out if you do not need them; existing projects stay unchanged.

  • imports: loads shared declaration files the same way import "…" does. They may contain pure fn, types, library, event / error, extern entity, and further imports — not entity, test, fuzz, or invariant. Put those under sources:.
  • Each path is tried next to the YAML, then in each library_paths directory, in order. The first file that exists wins.
  • If nothing matches, the error lists every directory that was tried. An unset ${VAR} stays as the text ${VAR} (it does not become the project directory).
  • A path listed in both sources: and imports: is treated as a project source.
  • import "./math.cam" / import "../lib/math.cam" stay relative to the importing file and do not search library_paths. A path without ./ or ../ (import "token/erc20-core.cam") tries the importer’s directory first, then library_paths.
  • Imported names are not namespaced. See Program Structure and the Contract Standard Library.

foundry

KeyNotes
solc_version, evm_versionCompiler / EVM revision
optimizer, optimizer_runs, via_irOptimizer
fuzz_runsDefault Foundry fuzz runs
profilesPer-profile fuzz/invariant runs, depth, fail_on_revert
remappingsSolc remappings

fuzz / invariant

SectionTypical keys
fuzzruns (default 256), seed, shrink, max_local_rejects
invariantruns, depth, fail_on_revert, seed, max_local_rejects

lean

KeyNotes
numericsoverflow-wrap (default; legacy alias bitvec), overflow-panic, or nat
proof_helpersHelper emission
intrinsicsopaque or executable

See also

Codegen Mapping

How Cambrian constructs lower on the public targets.

Solidity-EVM (--target evm)

CambrianSolidity
entity EContract E (flat file under src/)
membersStorage variables (+ transforms inlined into route bodies)
routesExternal / public functions
view routesview functions
msg::sender / msg::valuemsg.sender / msg.value (payable when value is read)
~> dest typed sendExternal call
deploy E(...)Factory / CREATE2 when deterministic
event / emitSolidity event / emit
error / throw NameCustom errors / revert
test / property / invariantFoundry tests / fuzz / invariant handlers

Lean-EVM (--target lean)

CambrianLean
entity stateStructure fields (plus ghost key lists for iterated maps when needed)
routesFunctions World → … (failing routes in an error monad / Except-style surface)
sends / deploysWorld updates (call, occupancy, balances)
property / testTheorems in *Spec.lean
invariantAction / step / runTrace style definitions + checks

Prefer reading a generated output tree for the exact file layout; lowering details evolve with each backend.

Grammar (Informal EBNF)

Informal sketch of the public surface. The authoritative grammar is cambrian-transpiler/src/cambrian.lalrpop.

Program        ::= TopLevel*

TopLevel       ::= ImportFile
                 | UseDecl
                 | PureFn
                 | TypeAlias
                 | RecordDecl
                 | EnumDecl
                 | Entity
                 | ExternEntity
                 | EventDecl
                 | ErrorDecl
                 | LibraryDecl
                 | UsingDecl
                 | TestDecl
                 | PropertyDecl
                 | InvariantDecl

ImportFile     ::= "import" StringLit
UseDecl        ::= "use" Ident
Entity         ::= "entity" Ident "{" EntityItem* "}"
ExternEntity   ::= SolidityImport? "extern" "entity" Ident "{" ExternRoute* "}"
SolidityImport ::= "@solidity_import" "(" StringLit ")"

EntityItem     ::= Const | Macro | RecordDecl | EnumDecl | EventDecl | ErrorDecl
                 | RoutesBlock | Member

RoutesBlock    ::= "routes" "{" Route* "}"
Route          ::= RouteKind Ident "(" Params ")" ReturnType? From? Where? "=>" Body
RouteKind      ::= /* empty */ | "view" | "pure" | "init" | "private" | "accept"
ReceiveRoute   ::= "accept" "receive" "(" ")" "=>" Body
FallbackRoute  ::= "fallback" "(" ")" "=>" Body
Body           ::= "[" Action* "]" | PhasedBody

Action         ::= Send | Deploy | Emit | Throw | Return | Let | IfActions
                 | VarCall | CallRoute | ForActions | …

Send           ::= Message? "~>" Expr SendOpts?
VarCall        ::= "var" Ident "=" Message "~>" Expr
CallRoute      ::= "call" Ident "(" Args ")"
ForActions     ::= "for" Pattern "in" Iter "=>" "[" Action* "]"
Emit           ::= "emit" Ident "(" Args ")"
Deploy         ::= "deploy" Ident DeployArgs? SendOpts?

Member         ::= Ident ":" Type "{" Transform* "}"
Transform      ::= "in" Ident "(" Pats ")" "=>" Expr
                 | PhaseTag ":" Expr

Expr           ::= … | "for" Pat "in" Iter "{" Expr "}" | "if" … | "match" …

PropertyDecl   ::= "property" StringLit "(" Params ")" "for" Ident With? Ctx? "{" PropBody "}"
TestDecl       ::= "test" StringLit "for" Ident ("skip" "from")? With? "{" TestStep* "}"
InvariantDecl  ::= "invariant" StringLit "for" InvariantTarget Attr* "{" InvBody "}"

Phased routes, match arms, collection literals, and iterator chains follow the forms shown in the Language Guide. TVM-only constructs (gosh::, rescue / recover, …) are not part of the public documentation surface.

See also Keywords and Operator Precedence.

Validation Rules

The transpiler reports diagnostics with short codes. Full text lives in cambrian-lang docs/LANGUAGE.md and cambrian-transpiler/src/validate/. Below is a public-target highlights list (EVM / Lean / Container).

Families

PrefixConcern
VEntity / route / member / phase / deploy / match
EDomain EVM / Solidity-language compatibility
LLean language / Lean–EVM pair
Ttest / property / fuzz
Iinvariant
Fproject.yaml
WWarnings / lints

Notable V codes

CodeSummary
V27 / V28Route-level vs per-phase where vs var scoping
V29Forbidden actions inside action-level for
V30 / V31extern entity uniqueness / route uniqueness
V32 / V33deploy / from arity (V33 is Domain EVM)
V34 / V35Unknown / mistyped emit
V36More than three indexed event params (EVM)
V38 / V39Unknown / mistyped throw Name
V40 / V41receive / fallback shape and uniqueness (EVM)
V42return(value) without -> T
V43^member outside transform bodies
V45Duplicate deploy collision risk
V46–V53Hex-address ambiguity, match exhaustiveness, if without else, bare stdlib (V49), type bridges (V50), unused let, record cycles, let some patterns

Notable E codes (EVM / Solidity)

CodeSummary
E07 / E17–E21 / E23Solidity lowering limits (iterators, maps, tuples, casts)
E09fromrequire(msg.sender == …) (warning)
E12evm:: off the EVM domain
E16Unknown namespace / address_of without deterministic mode / encode misuse
E22Typed send to unknown entity (declare extern entity)

Notable L codes (Lean)

CodeSummary
L5 / L6Vacuous throw / return expectations
L8Send dest not a static in-program address
L9 / L11Failing self-call without a fail surface
L10 / L12Warnings: sends inside invariant steps; TVM rescue/recover dropped on Lean
L13 / L14Missing crypto lowering / unknown types

Notable T / I / F / W

CodeSummary
T10–T12, T15–T23Property params, assume/bound placement, with vs ctx, instance bindings
I1–I6, I8–I16Actions, checks, multi-entity qualification, trace::*
F1 / F3Coverage requires parent enabled; engine name
F4Imported file (in-language import or yaml imports:) declares entity / test / fuzz / invariant
F5yaml imports: or a bare import "…" did not resolve against the first search directory or any library_paths root
W7 / W8Fuzz fallback types; unpinned forall in concrete tests

Many legacy platform constructs produce E-family diagnostics on EVM; prefer writing portable Cambrian that stays within msg:: / sys:: / std:: / evm:: as documented in this book.

FAQ

What is Cambrian?

A language for stateful, message-based programs (entities, routes, and member transforms). One .cam source can target Solidity-EVM and Lean-EVM backends.

How do I run tests?

  • EVM: cambrian-transpiler … --target evm then forge test in the output directory.
  • Lean: --target lean then lake build, or pass --check-lean.

See Testing.

What is Lean-EVM?

--target lean shares the EVM domain with Solidity (calls, value, CREATE2 rules) but emits Lean 4 for machine-checked reasoning instead of Solidity text.

Do I need project.yaml?

Single-file programs can be passed directly on the CLI. Multi-entity apps, deterministic addresses, Foundry/Lean knobs, shared source lists, and library search roots (library_paths / imports) use project.yaml (reference).

Why did the compiler reject min(a, b)?

Bare stdlib names are invalid (V49). Write std::math::min(a, b).

Is Cambrian open source?

No. The language, transpiler, and cambrian-lang workspace are developed in proprietary mode and are not published for public download. This book is the public language guide; access to the toolchain is arranged separately. If you are interested in collaborating with the development team, contact [TBD].

Where is the language specified?

This book is the public guide. The cambrian-lang repo also maintains docs/LANGUAGE.md, docs/STDLIB.md, and the parser grammar — when docs disagree with code, trust the transpiler and fix the docs.

Can I mix hand-written Solidity with Cambrian?

Yes, via extern entity (and optional @solidity_import) and Foundry remappings for dependencies. See Extern Entity and the governor / OZ integration examples in the lang repo.