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.

Cambrian is open source (GPL-3.0). The transpiler, standard library, and examples live at github.com/Ordocode/cambrian-lang; prebuilt binaries are on the Releases page.

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 larger(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 Verification — test, 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 open source (GPL-3.0-only) and lives in the cambrian-lang repository. Download a prebuilt binary or build it with the standard Rust toolchain; then you can transpile .cam programs to the targets evm and lean.

Steps

  1. Prerequisites — Rust via rustup (only to build from source); optional Foundry and Lean 4 + Lake for EVM / Lean targets.
  2. Building from Source — download a prebuilt binary, or clone cambrian-lang and run cargo build --release -p cambrian-transpiler; put cambrian-transpiler on your PATH.

Once the binary works, continue with the Quick Start.

Prerequisites

Install only what you need for the targets you plan to use. Building the transpiler from source requires Rust; a prebuilt binary needs no Rust at all. EVM and Lean tooling are optional until you exercise those backends.

Rust (for building from source)

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.

Getting the source (cambrian-lang)

The Cambrian transpiler, reference contracts and component library live in the open-source cambrian-lang repository, licensed under the GNU GPL v3.0 only:

git clone https://github.com/Ordocode/cambrian-lang.git

Build from its root (see Building from Source). Sample contracts such as contracts/counter.cam are in that tree. If you only need the CLI, download a prebuilt binary instead.

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.

Tested versions

ToolVersion pinned or tested
Leanleanprover/lean4:v4.29.1 (generated lean-toolchain; override with lean.lean_toolchain in project.yaml)
solc0.8.24, evm_version = "prague" (generated foundry.toml)
Foundry1.x (CI uses v1.7.1)

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 from sourcerustc --version
Foundry (forge)Running EVM testsforge --version
elan / LakeBuilding Lean outputlake --version

Next: Building from Source.

Building from Source

Prebuilt binaries

Each tagged release of cambrian-lang publishes the cambrian-transpiler CLI on the Releases page for Linux (x86_64, aarch64), macOS (arm64, x86_64) and Windows (x86_64). Download cambrian-transpiler-<version>-<platform>.tar.gz (.zip on Windows), unpack it, and put cambrian-transpiler on your PATH. The sample contracts used in this book are in the repository, not in the archive.

Build

Clone the repository and build the transpiler from its root:

git clone https://github.com/Ordocode/cambrian-lang.git
cd cambrian-lang
cargo build --release -p cambrian-transpiler

(cargo build --release builds the whole workspace, including the transpiler.)

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] [--check-lean] [--check]
                    [--diagnostic-format human|json] [--max-errors N]

cambrian-transpiler --project <project.yaml> [--check] [--check-lean]

cambrian-transpiler --version [--json]
FlagMeaning
-o <dir>Write generated files under <dir>. Default: build/<entity_name>-entity/ (snake_case, e.g. build/counter-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). The output dir and target come from the YAML; -o / --target are ignored with a warning.
--checkParse, validate and check target compatibility; write nothing.
--dump-astPretty-print the parsed program and exit (no codegen).
--check-leanAfter --target lean, run lake build in the output directory.
--diagnostic-format <fmt>human (default) or json diagnostics.
--max-errors <N>Cap recovered parse diagnostics (default 32).
--versionPrint the compiler build identity (--json for machine-readable output).

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.

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 (minus its licence header comment), 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; a single-file invocation emits all of them (the first one names the default output directory), while programs split across several files 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/
  .cambrian-manifest.json   # list of generated files with SHA-256 hashes
  src/
    Counter.sol             # Solidity ^0.8.24: Counter + CambrianFactory

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.

On EVM every entity is deployed with CREATE2 through the generated CambrianFactory contract, emitted in the same file. Counter’s constructor takes the factory address, so deploy the factory and call deployCounter() on it rather than new Counter() directly.

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-git when needed, then prints the usual forge test / profile hints. In project mode the contracts live in src/_counter_project.sol; src/Counter.sol is a stub that imports it.

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
    Core.lean             # vendored domain-free foundations (U256, results, ...)
    Evm.lean              # vendored EVM domain (MsgCtx, SysCtx, WorldState)
    Prelude.lean          # imports SimpAttrs, Core and Evm
    Generated/
      World.lean
      Counter.lean
      CounterRoutes.lean
      CounterSpec.lean    # when test / property sources are included

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

Useful flags while iterating

# Parse, validate and check target compatibility without writing files
./target/release/cambrian-transpiler contracts/counter.cam --check --target lean

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

Next steps

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 — shared declarations only, no entities
error ZeroAmount();

pure fn smaller(a: U256, b: U256) -> U256 {
    if a < b { a } else { b }
}
// Vault.cam
import "./math.cam"

using { smaller } for U256;

entity Vault {
    const CAP: U256 = 1_000_000

    routes {
        deposit(amount: U256)
            where amount > 0 : throw ZeroAmount()
        => []
    }

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

Semantics:

  • Imports are transitive. Each file is loaded once, so an import cycle is not an error — already-visited files are simply skipped.
  • Importable: pure fn, record, enum, type, event, error, extern entity, library, using … for …;, and other imports. Constants live inside an entity or library (there is no top-level const).
  • 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 / enumShared types
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

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

pure fn larger(a: U256, b: U256) -> U256 {
    if a >= b { a } else { b }
}

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

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

        view get_peak() -> U256 => [
            return(m_peak)
        ]

        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
    }

    m_peak: U256 {
        in deposit(amount) => larger(m_peak, amount)
    }
}

Ordering inside an entity

  • Routes live in a routes { } block (several blocks are allowed and are merged, but one is conventional).
  • Members are declared in the entity body alongside that block.
  • Type aliases, records, enums, constants, macros, events, and errors 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) => m_balances.insert(account, m_balances[account] + amount)
    }
}

entity Shop {
    routes {
        #[factory_only]
        init init_shop(ledger: Address<Ledger>) => []

        purchase(item_id: u64)
            where msg::value > 0 : throw 1
        => [
            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 read by destructuring in let bindings:

let (lo, hi) = split(total);

Tuples are supported only as multi-value return types and in let destructuring. They cannot be used as member types or collection elements (Vec<(u64, address)>); define a record instead.

Type Annotations

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

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

let bindings in routes and blocks do not take a type annotation; their type is inferred from the right-hand side. Use a cast or a typed parameter when you need a specific width.

Type Casts

The as keyword performs explicit type conversion between compatible types:

let big = 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
usizeuint256 on EVM, Nat on LeanCollection 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

TypeEVM typeUse
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)
bytes32Fixed 32-byte value (hashes)

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
pubkeyA 256-bit key value (bytes32 on EVM). It is not part of the EVM message/system context — prefer address and msg::sender for access control.

Summary

TypeCommon use
boolFlags, conditions
u8…u128, i8…i128Counters, IDs, offsets
U256Balances 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 (use v.len() == 0 to test for emptiness)
v.push(elem)Append (as the tail of a Vec member transform on EVM)
v[index]Element at index

array(...) builds a vector value, e.g. as a member default:

m_empty: Vec<u64> = array() {}
m_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(src, dst, amount) => m_balances
        .insert(src, m_balances[src] - amount)
        .insert(dst, m_balances[dst] + amount)
}
MethodDescription
m[key]Read; returns the type’s default (e.g. 0) when the key is absent
m.insert(k, v) / m.update(k, v)Write (in a member transform; chainable)
m.remove(key)Delete (in a member transform)
m.exists(key) / m.contains(key)Presence

Option<T>

some(value) / none. Unwrap an option with match:

let result = match m_limit {
    some(val) => val * 2,
    none => 0
};

There are no .unwrap() / .unwrap_or() / .is_some() methods — always match on the two cases.

Address<Entity>

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

m_root: Address<RootToken> {}     // named sends
m_backup: address {}              // value transfers

A named send to a plain address is rejected (V23): type the destination as Address<Entity> so the message is checked against the target’s routes. For a foreign contract, declare it with extern entity and use Address<ThatExternEntity>.

Predicting addresses (EVM)

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 sendChecked against the target’s routesRejected (V23)
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 or at program scope (shared by all entities) 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:

m_listing: Listing {
    in list(price) => {
        let listing = Listing {
            seller: msg::sender,
            price: price,
            active: true
        };
        listing
    }
}

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. A literal that omits a field, names an unknown field, or sets a field twice is rejected.

A record literal may appear in block contexts: a pure fn body, a { ... } block (including a let inside it), or a member-transform body wrapped in braces. It cannot be the right-hand side of a route-action let or a direct call argument — bind it in a block let first.

Field Access

Fields are accessed with dot notation:

let price = listing.price;
let who = listing.seller;
let open_price = if listing.active { listing.price } else { 0 };

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 };
    updated.price
}

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. As with any record literal, wrap the transform body in braces:

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:

m_listing: Listing {
    in relist(new_price) => {
        m_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 {
        #[factory_only]
        init create(buyer: address, seller: address, amount: U256) => []

        release()
            where msg::sender == m_deal.buyer : throw 100
               && 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) => {
        let bid = Bid {
            bidder: msg::sender,
            amount: amount,
            timestamp: sys::now
        };
        m_bids.push(bid)
    }
}

Nesting

Records can contain other records as field types:

entity Vault {
    record Config {
        fee_rate: u64,
        limits: Limits
    }

    record Limits {
        max_deposit: U256,
        min_deposit: U256
    }
}

Nesting works the same for program-scope records. A record literal cannot be written inline as a field value; bind the inner record with a block let first:

m_config: Config {
    in configure(max) => {
        let limits = Limits { max_deposit: max, min_deposit: 1 };
        Config { fee_rate: 3, limits: limits }
    }
}

Access nested fields with chained dot notation:

let max = m_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 code = match status {
    Status::Pending => 1,
    Status::Active => 2,
    _ => 0
};

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

Use _ as a catch-all. A match must be exhaustive: either list every variant or end it with a _ => arm; a match that misses a variant is rejected. A _ after all variants are already covered is unreachable and draws a warning, as do duplicate arms and arms after _ (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 (no trailing ;), inside an entity or at program scope:

entity Token {
    type Amount = U256
    type Timestamp = u64
}

After declaration, the alias can be used in place of the original type. The two names are interchangeable: a U256 parameter or a literal can be assigned to an Amount member and vice versa.

Usage

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

entity Token {
    type Amount = U256

    record Transfer {
        sender: address,
        recipient: address,
        value: Amount
    }

    routes {
        mint(to: address, amount: Amount) => []

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

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

    m_balances: HashMap<address, Amount> {
        in mint(to, amount) => m_balances.insert(to, m_balances[to] + amount)
        in transfer(to, amount) => m_balances
            .insert(msg::sender, m_balances[msg::sender] - amount)
            .insert(to, m_balances[to] + 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. Declare an alias at program scope (outside any entity) to share it between entities.

Aliases vs. Newtypes

Type aliases are transparent – two aliases of the same type are not distinguished. This means you cannot use aliases to prevent accidental mixing of conceptually different values:

type UserId = u64
type ProductId = u64

// Accepted: both names stand for u64.
pure fn as_product(user: UserId) -> ProductId {
    user
}

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 within decimal and binary literals 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 a lowercase 0x (digits may be upper- or lowercase; underscores are not allowed):

let flags = 0xFF;
let mask = 0xDEADBEEF;

Binary

Binary literals start with a lowercase 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 = "";

String and byte literals support the escapes \n, \r, \t, \0, \\, and \xHH (one byte, two hex digits); other text, including non-ASCII UTF-8, is taken verbatim. A literal cannot contain a " character — there is no \" escape.

let two_lines = "first\nsecond";
let prefix = b"\x19\x01";

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 collection is written with empty braces, e.g. as a member default (maps start empty anyway, so this is mostly for explicitness):

m_scores: HashMap<address, u64> = {} {}

Vec (Array)

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

m_empty: Vec<u64> = array() {}
m_nums: Vec<u64> = array(1, 2, 3) {}

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:

m_owner: Option<address> {
    in claim() => some(msg::sender)
    in release() => none
}

Record Literals

Records are constructed by specifying values for all fields:

record Transaction {
    id: u64,
    value: U256,
    sender: address
}
m_last: Transaction {
    in pay(amount) => {
        Transaction {
            id: 1,
            value: amount,
            sender: msg::sender
        }
    }
}

A record literal is allowed in block contexts (braced transform bodies, { ... } blocks, pure fn bodies), not as a route-action let value. 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 bracesEmpty collection{}
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.exists(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"
};

if and match (and record literals) are written as a let value, as the body of a block, pure fn, or closure, or as a braced member-transform body (in r(x) => { if … }). They cannot be nested directly inside another expression or call such as return(if …); bind the result with let first.

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 } }
}

There is no else if; nest an if inside the else block instead.

Iteration (for)

Expression-level for produces a value (a Vec) by mapping over an iterator. It is written in a block or pure fn body (wrap it in { } to use it as a route let value):

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

pure fn total(xs: Vec<u64>) -> u64 {
    xs.fold(0, |acc, x| acc + x)
}

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. They work on every integer type, signed or unsigned, in member transforms, routes, and pure fn bodies:

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:

entity Hasher {
    routes {
        mix(b: U256) => []
    }

    m_total: U256 {
        in mix(b) => m_total + b                 // checked -- reverts on overflow
    }

    m_hash: U256 {
        in mix(b) => (m_hash *% 31) +% b         // wrapping -- 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)
as
*, /, %, *%
+, -, +%, -%
<<, >>
<, <=, >, >=
==, !=
&
^
|
&&
Lowest||

Note that, unlike Rust (but as in C), comparisons bind tighter than the bitwise operators: flags & MASK != 0 parses as flags & (MASK != 0). Parenthesize bitwise tests: (flags & MASK) != 0.

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

There is no else if. To test several conditions, nest an if inside the else block:

let tier = if amount >= 10000 {
    3
} else {
    if amount >= 1000 { 2 } else { 1 }
};

In Member Transforms

if/else is frequently used in member transforms to compute new state conditionally. Wrap the if in braces so the transform body is a block:

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,
    _ => 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 limit = match m_limit {
    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:

let digits = match value {
    0 => 1,
    1 => 1,
    _ => 2
};

In Route Actions

There is no action-level match. In a route body, compute a value with let … = match …; and branch on it with an if action:

routes {
    execute(action: Action) => [
        let amount = match action {
            Action::Transfer(value) => value,
            _ => 0
        };
        if amount > 0 => [
            ~> m_recipient with { value: amount }
        ]
    ]
}

Exhaustiveness

A match must be exhaustive. On an enum, Option, or bool, either cover every case or end with a _ => arm; a match on integers always needs a _ => arm. A non-exhaustive match is rejected. A _ after arms that already cover every case is unreachable and draws a warning, as do arms after a _ (or a bare identifier pattern) and duplicate arms.

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.

Types of Bindings

let bindings take no type annotation; the type is inferred from the right-hand side. Use a cast when you need a particular width:

let count = 42;              // inferred as integer
let name = "hello";          // String
let bal = 0 as U256;         // explicit width via cast

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 sender = msg::sender;
        let sender_bal = m_balances[sender];
        let to_bal = m_balances[to];
        m_balances
            .insert(sender, sender_bal - amount)
            .insert(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:

let x = 10;
let result = {
    let y = 20;
    x + y        // evaluates to 30
};
// y is not visible here

A later let may reuse a name that is already in scope. The new binding shadows the old one from that point on; the earlier value is not modified:

{
    let x = amount + 1;
    let x = x * 2;
    x
}

Inside a nested block used as a value (let r = { … };), use a fresh name: re-binding an outer name there does not yet compile on EVM.

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
    ]
}

let bindings (like var captures and emit) end with a semicolon. Other actions, such as sends, are simply written one after another with no separator.

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 = m_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 never fails: a missing key yields the value type’s default (0 for numbers, false for bool, and so on), just like a Solidity mapping. A stored 0 and an absent key look the same through []; use m.exists(k) (or its alias m.contains(k)) when membership matters:

let known = m_balances.exists(account);

Method Calls

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

Vec Methods

let size = m_items.len();
let first = m_items[0];
let empty = m_items.len() == 0;

push appends an element; it is used as the result of a member transform (in add(x) => m_items.push(x)).

HashMap Methods

let has_key = m_map.exists(key);
let also_has = m_map.contains(key);
let empty = m_map.is_empty();

Writes happen in member transforms: m.insert(k, v), m.update(k, v), and m.remove(k) each return the updated map.

Option Values

Option<T> has no accessor methods: unwrap, unwrap_or, is_some, is_none, and expect are rejected at compile time, as are .get / .set on maps and vectors (use m[k], v[i], and the member-transform writes). Take an Option apart with match:

let val = match opt {
    some(v) => v,
    none => 0
};

String Methods

let size = name.len();

len() on a String counts UTF-8 bytes, not characters.

Method Chaining

Map writes return the updated map, so they can be chained:

m_balances: HashMap<address, U256> {
    in transfer(to, amount) => m_balances
        .insert(msg::sender, m_balances[msg::sender] - amount)
        .insert(to, m_balances[to] + 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;

Widening between unsigned integer types happens automatically: in small + x with small: u8 and x: u64, small is widened to u64. An explicit as is needed to narrow, or to pin a specific width. See Type Widening and Arithmetic and Logic.

Common Cast Patterns

// Narrowing: checked -- reverts if the value does not fit
let byte_val = large_num as u8;

// Widening: always safe
let big_val = small_num as u128;

Narrowing with as does not truncate: if the value is out of range for the target type, the transaction reverts.

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 last_amount = m_bids[m_bids.len() - 1].amount;

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 pointsNo
MembersState variables with transformsNo

A useful entity has 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
    }

    m_owner: address {}
    m_balance: U256 {}
}

Multiple Entities

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

entity Broker {
    routes {
        #[factory_only]
        init setup(shop: Address<Shop>) => []

        place_order(item_id: u64, quantity: u64) => [
            fulfill(item_id, quantity) ~> m_shop
        ]
    }

    m_shop: Address<Shop> {
        in setup(shop) => shop
    }
}

entity Shop {
    routes {
        #[factory_only]
        init setup(ledger: Address<Ledger>) => []

        fulfill(item_id: u64, quantity: u64) => [
            log_sale(item_id, quantity, msg::sender) ~> m_ledger
        ]
    }

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

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

    m_sales: u64 {
        in log_sale(item_id, quantity, buyer) => m_sales + 1
    }

    m_units: HashMap<u64, u64> {
        in log_sale(item_id, quantity, buyer) =>
            m_units.insert(item_id, m_units[item_id] + quantity)
    }
}

Typing a destination as Address<E> (rather than plain address) records which entity it points to; the compiler then reports a send to a route that E does not declare. A named send needs such a typed destination — a plain address only takes value transfers.

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) => []
    }

    m_fees: U256 {
        in transfer(to, amount) => m_fees + 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 {
    error ZeroDeposit();
    error Unauthorized();
    error InsufficientBalance(available: U256, requested: U256);

    routes {
        #[factory_only]
        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() => []
    }

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

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

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 and is meant to be invoked only by other routes of the same entity, 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] [-> ReturnType] [where clause] => [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
Return typeNo-> T when returning a value (V42 if return(expr) lacks it)
Where clauseNoPreconditions — Where Clause
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:

pay(to: address) => [
    let fee = msg::value / 100;
    ~> to with { value: msg::value - fee }
    ~> m_treasury with { value: fee }
]

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 }
        ]
        log_sale(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 sender = msg::sender;
            let sender_bal = m_balances[sender];
            let to_bal = m_balances[to];
            m_balances
                .insert(sender, sender_bal - amount)
                .insert(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 a return type:

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

Return Type

A view route should declare a return type with -> Type (omitting it is only a warning, but such a view is useless to callers) 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 or value (~>)
Use if/else, matchDeploy entities
Compute intermediate valuesEmit events
call a state-changing route

Cambrian rejects a view that calls a route with member transforms or state-changing actions (directly or through further calls). On EVM it also rejects a view whose body emits, sends, or deploys, since Solidity does not allow these in a view function.

Computed Views

View routes can perform computations on state before returning:

view get_balance_of(account: address) -> U256 => [
    return(m_balances[account])
]

Indexing a HashMap with a missing key yields the value type’s default (0 here), so no explicit “absent” handling is needed. Nested maps are indexed twice:

view is_approved(owner: address, spender: address) -> bool => [
    let allowance = m_allowances[owner][spender];
    return(allowance > 0)
]

Conditional Returns

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

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

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[proposal_id])
]

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[account])
        ]

        view allowance(owner: address, spender: address) -> U256 => [
            return(m_allowances[owner][spender])
        ]
    }

    m_name: String {}
    m_symbol: String {}
    m_total_supply: U256 {}
    m_balances: HashMap<address, U256> {}
    m_allowances: HashMap<address, HashMap<address, U256>> {}
}

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 => [
    let m = if a >= b { a } else { b };
    return(m)
]

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

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 {
        // `rate` is scaled by 1e6
        pure convert(amount: U256, rate: U256) -> U256 => [
            let product = amount * rate;
            return(product / 1_000_000)
        ]

        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:

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

A route literally named constructor (without the init keyword) is also treated as the init route.

On EVM, entities are always deployed via CREATE2 through the generated CambrianFactory, so the init route carries the #[factory_only] attribute: the generated initialize() accepts only calls from the factory. Leaving it out is an error in EVM project builds and a warning in single-file EVM builds.

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 {
        #[factory_only]
        init setup(
            name: String,
            symbol: String,
            decimals: u8,
            owner: address
        ) => []
    }

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

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

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

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

    m_total_supply: U256 {
        in setup(_, _, _, _) => 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 — identity values first, then the init arguments:

// In another entity's route body, deploying the Token above:
deploy Token("MyToken", "MTK", 18, msg::sender)

Do not read msg::sender inside the init route itself: on EVM the caller of initialize() is the factory, not the account that requested the deployment. Pass the owner (or mint recipient) as an explicit parameter, as owner is above. Reading msg::sender there is diagnosed like a missing #[factory_only]: an error in EVM project builds, a warning in single-file EVM builds.

Init Actions

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

#[factory_only]
init setup(owner: address, treasury: address) => [
    emit Initialized(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 setup(_, _, _, owner) => owner
}

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

Complete Example

A vault entity with an init route:

entity Vault {
    const MIN_DEPOSIT: U256 = 100

    routes {
        #[factory_only]
        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, 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). Each alternative carries its own : throw, and all alternatives must use the same one:

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

From a stored address

from m_member compares the sender with an address held in a member (msg.sender == m_owner on EVM):

set_fee(bps: u64)
    from m_owner : 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)

from Entity(...) takes as many arguments as the target entity has identity members (zero for singletons: from Registry()).

extern entity targets skip V33. Plain address members used as senders can be checked with from m_owner (above) or, equivalently, where msg::sender == m_owner.

On EVM, 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 Entity(…) on EVM
Plain address stored in a memberfrom m_owner or where 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:

buy()
    where msg::value > 0 : throw ZeroValue()
    && @is_owner() : throw Unauthorized()
    && m_whitelist.exists(msg::sender) : throw NotWhitelisted()
    && (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. Actions follow one another without separators; only let, var, and emit end with a semicolon:

pay(to: address) => [
    let fee = msg::value / 100;
    let net = msg::value - fee;
    ~> to with { value: net }
    ~> m_treasury with { value: fee }
]

An empty action list is written as []:

increment() => []

Action Types

ActionSyntaxChapter
Send messageroute(args) ~> destSend Messages
Plain transfer~> dest with { value: amount }Send Messages
Deploydeploy Entity(args)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:

forward() => [
    let fee = msg::value * 3 / 1000;
    let payout = msg::value - fee;
    ~> m_recipient with { value: payout }
    ~> m_fee_collector with { value: fee }
]

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

Execution Order

An unphased route runs in this order:

  1. from checks, then where checks — any failure reverts the whole route.
  2. Every member transform is computed (from the values the members had before the route) and the results are stored.
  3. Actions execute in declaration order: let bindings are evaluated, sends, deploys, and emits happen, conditionals branch on their guard.

The combination of actions and member transforms defines the complete effect of a route invocation. On EVM this is the checks-effects-interactions order: storage is written before any external call is made.

Actions and State

State mutations happen through member transforms, not through actions. This separation is a core design principle of Cambrian. Transforms are stored before the actions run, but which member value an action reads depends on the action:

  • Arguments of outbound effects — sends (~>, including var captures), call, and deploy — read the value from before the route’s transforms.
  • Everything else — let bindings, emit arguments, if conditions, and return — reads the new value.
entity Observer {
    routes {
        notify(count: u64) => []
    }

    m_last: u64 {
        in notify(count) => count
    }
}

entity Counter {
    event Counted(count: u64);

    routes {
        #[factory_only]
        init setup(observer: Address<Observer>) => []

        increment_and_notify() => [
            // send argument: the value *before* the increment
            notify(m_count) ~> m_observer
            // emit argument: the value *after* the increment
            emit Counted(m_count);
        ]
    }

    m_observer: Address<Observer> {
        in setup(observer) => observer
    }

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

This lets a route pay out a balance that its own transforms reset to zero, while still writing storage before the transfer:

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

with in claim() => m_pending.insert(msg::sender, 0). Write the member read directly in the send: let amount = m_pending[msg::sender]; would already read the reset value (zero). To carry an old value through a let, bind it in an earlier phase.

Complete Example

extern entity Token {
    route transfer(to: address, amount: U256);
}

entity Exchange {
    error SlippageExceeded();

    routes {
        #[factory_only]
        init setup(token: Address<Token>, rate: U256) => []

        buy(min_out: U256)
            where msg::value > 0 : throw 100
        => [
            let amount_out = msg::value * m_rate / 1_000_000;
            if amount_out < min_out => [
                throw SlippageExceeded()
            ] else [
                transfer(msg::sender, amount_out) ~> m_token
            ]
        ]
    }

    m_token: Address<Token> {
        in setup(token, _) => token
    }

    m_rate: U256 {
        in setup(_, rate) => rate
    }
}

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(to, amount) ~> m_token

The message is written as a plain route call, route(args) — not Entity::route(args). The destination must be typed — an Address<Entity> member or parameter, or a predicted Entity.address(...) — so the compiler knows which entity’s route is meant. The target entity must be in the project or declared extern entity (E22 otherwise), and must declare the route. A named send (or a var capture) to a plain address is rejected (V23); plain address destinations only take value transfers.

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. As with any named send, m_token must be typed (Address<Token> for a project or extern entity Token that declares balanceOf). 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:

start() ~> m_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 Observer {
    routes {
        on_transfer(sender: address, to: address, amount: U256) => []
    }

    m_count: u64 {
        in on_transfer(sender, to, amount) => m_count + 1
    }
}

entity Token {
    error InsufficientBalance(available: U256, requested: U256);

    routes {
        #[factory_only]
        init setup(holder: address, supply: U256, observer: Address<Observer>) => []

        transfer(to: address, amount: U256)
            where m_balances[msg::sender] >= amount : throw InsufficientBalance(m_balances[msg::sender], amount)
            && to != msg::sender : throw 101
        => [
            on_transfer(msg::sender, to, amount) ~> m_observer
        ]
    }

    m_balances: HashMap<address, U256> {
        in setup(holder, supply, _) => m_balances.insert(holder, supply)
        in transfer(to, amount) => {
            let sender = msg::sender;
            m_balances
                .insert(sender, m_balances[sender] - amount)
                .insert(to, m_balances[to] + amount)
        }
    }

    m_observer: Address<Observer> {
        in setup(_, _, 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)

On EVM, the 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 {
    error Underfunded();

    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 {
    error Unauthorized();
    error AlreadyReleased();

    identity m_buyer: address
    identity m_seller: address

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

        release()
            from m_buyer : throw Unauthorized()
            where !m_released : throw AlreadyReleased()
        => [
            ~> m_seller with { value: m_amount }
        ]
    }

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

    m_released: bool {
        in release() => true
    }
}

(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 {
    forward() => [
        if msg::value > 0 => [
            ~> m_recipient with { value: msg::value }
        ]
    ]
}

If/Else

Choose between two action sequences:

routes {
    forward() => [
        if msg::value >= m_threshold => [
            emit LargeTransfer(msg::sender, msg::value);
            ~> m_cold_wallet with { value: msg::value }
        ] else [
            ~> m_hot_wallet with { value: msg::value }
        ]
    ]
}

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 [
    enqueue(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. An if condition reads the members after the route’s transforms, so the previous bid and bidder are bound in an earlier phase, before the transforms overwrite them:

entity Auction {
    routes {
        bid()
            where msg::value > m_highest_bid : throw 100
        => [
            snap: [
                let prev_bid = m_highest_bid;
                let prev_bidder = m_highest_bidder;
            ]
            refund: [
                if prev_bid > 0 => [
                    ~> prev_bidder with { value: prev_bid }
                ]
            ]
        ]
    }

    m_highest_bid: U256 {
        in bid() => refund: msg::value
    }

    m_highest_bidder: address {
        in bid() => refund: msg::sender
    }
}

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) => [
            deploy UserVault(user) with { value: 1_000_000 }
        ]
    ]
}

Conditionals with Let Bindings

Let bindings can precede conditional actions:

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

Complete Example

entity Escrow {
    event Resolved(indexed deal_id: u64, approved: bool);

    routes {
        #[factory_only]
        init setup(deal_id: u64, buyer: address, seller: address, arbiter: address) => []

        deposit()
            where msg::sender == m_buyer : throw 201
        => []

        resolve(approved: bool)
            where msg::sender == m_arbiter : throw 200
            && !m_resolved : throw 202
        => [
            if approved => [
                ~> m_seller with { value: m_amount }
                emit Resolved(m_deal_id, true);
            ] else [
                ~> m_buyer with { value: m_amount }
                emit Resolved(m_deal_id, false);
            ]
        ]
    }

    m_deal_id: u64 {
        in setup(deal_id, _, _, _) => deal_id
    }
    m_buyer: address {
        in setup(_, buyer, _, _) => buyer
    }
    m_seller: address {
        in setup(_, _, seller, _) => seller
    }
    m_arbiter: address {
        in setup(_, _, _, arbiter) => arbiter
    }
    m_amount: U256 {
        in deposit() => m_amount + msg::value
    }
    m_resolved: bool {
        in resolve(approved) => true
    }
}

Return Values

The return action produces a value from a route. view and pure routes must include one; it provides the mechanism for returning computation results to callers.

Syntax

return(expression)
return(expr1, expr2)   // multi-value, for `-> (T1, T2)`

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

In View Routes

A view route should end with a return action:

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

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

In Pure Routes

Likewise for pure routes:

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 ordinary expression (arithmetic, function calls, member reads). if, match, and { ... } blocks are not allowed directly inside return(...); bind them with let first:

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

Return with Let Bindings

Let bindings can compute intermediate values before the return:

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

Return Type Agreement

The type of the return expression must match the declared return type of the route. A route that declares -> T but never returns a value is rejected, and so is a value of an obviously different kind (a string for a number, say):

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

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

Integer widths are not yet diagnosed here, so make them agree yourself: to return a U256 from a -> u8 route, narrow it with as (which reverts on overflow) or declare the wider return type.

Multiple return values

Declare a tuple return type and pass several expressions to return:

view stats() -> (U256, bool) => [
    return(m_total, m_total > 0)
]

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. A regular route that declares -> T may still return(...); the returned expression sees member values after the route’s transforms.

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 => [
            let p = if token == m_token_a {
                m_reserve_b * 1_000_000 / m_reserve_a
            } else {
                m_reserve_a * 1_000_000 / m_reserve_b
            };
            return(p)
        ]

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

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

    // set by the pool's mutating routes (omitted)
    m_token_a: address {}
    m_reserve_a: U256 {}
    m_reserve_b: U256 {}
    m_lp_balances: HashMap<address, U256> {}
    m_total_lp: U256 {}
}

Call (Private Routes)

The call action invokes another route on the same entity. It is the way to run a private route — a route intended only for internal use by the entity’s other routes.

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
EVMAn internal Solidity function (_apply_total), called directly
LeanInternal transition helper in generated modules

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

A private route is not part of the contract’s external interface, and call is an ordinary in-contract call: msg::sender inside the private route is the original caller.

Restrictions

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

A view route may not call a route that has member transforms or state-changing actions, directly or through further calls (V9).

When to use

private route + call splits one public entry point into internal steps — shared checks, multi-step setup, or helper routes that only make sense inside the entity. For shared computation without state changes, a pure function is usually simpler.

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 state-changing route (not from view or pure routes). Like let and var, emit ends with a semicolon. 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() => m_balance + msg::value
    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, an optional initial value, and a block of transforms (the block is required, but may be empty):

m_name: Type {
    in route_name(params) => new_value_expression
    in another_route(params) => another_expression
}

m_name: Type = initial_value { ... }

Without = initial_value, a member starts at its type’s default (zero, false, empty) unless the init route’s transform sets it:

m_limit: U256 = 1_000 {}

m_open: bool = true {
    in close() => false
}
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

Which value you see depends on where you read it:

  • from / where clauses and transform bodies see the value from before the route (use ^m_x inside a transform to read another member’s new value).
  • In an unphased route, the action list runs after all transforms are stored. Arguments of sends, call, and deploy still read the value from before the route; let, emit, if conditions, and return read the new value (see Actions and State).

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 {
        #[factory_only]
        init setup(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
            && to != msg::sender : throw 102
        => []
    }

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

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

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

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

    m_balances: HashMap<address, U256> {
        in mint(to, amount) => m_balances.insert(to, m_balances[to] + amount)
        in transfer(to, amount) => {
            let sender = msg::sender;
            m_balances
                .insert(sender, m_balances[sender] - amount)
                .insert(to, m_balances[to] + 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. When the new value needs let bindings, if, match, or a record literal, wrap it in a block: in r(x) => { ... }.

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() => m_balance + msg::value
    in withdraw(amount) => m_balance - amount
}

Parameter Matching

Transform parameters bind the route parameters by position. The names can differ, but list one pattern per route parameter, in order; a transform that binds a different number of parameters than the route declares is rejected. (in r() with no bindings at all is also allowed, when the transform needs none of the parameters.)

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

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

Ignoring Parameters

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

m_owner: address {
    in setup(_, _, 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 sender = msg::sender;
        let sender_bal = m_balances[sender];
        let to_bal = m_balances[to];
        m_balances
            .insert(sender, sender_bal - amount)
            .insert(to, to_bal + amount)
    }
}

Conditional Transforms

Transforms can use if/else and match inside a block:

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 setup(_, _, _) => 0
    in deposit() => m_balance + msg::value
    in withdraw(amount) => m_balance - amount
    in transfer_out(_, amount) => m_balance - amount
    in receive() => m_balance + msg::value
}

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 of other members, 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.insert(msg::sender, true)
}

Other map updates are m.update(k, v) and m.remove(k); calls chain (m.insert(a, x).insert(b, y)). Reading m[k] for a missing key yields the value type’s default.

Vec Updates

m_history: Vec<u64> {
    in log(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

^m_x evaluates to the value m_x will have after the current route’s transforms. It is only allowed inside member transform bodies — not in where clauses, from clauses, or the action list (actions run after the transforms are stored; see Actions and State for which value each action reads).

Common Patterns

Derived State

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

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

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: U256 {
    in add_item(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(new_price) => ^m_price * 3 / 100
}

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

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

When the referenced member’s transform uses a route parameter (new_price, item above), bind that parameter by name in the transform that reads ^, rather than _: the Lean target currently fails to build otherwise.

Without ^ (Pre-Transform Values)

Inside a transform, member references without ^ 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 that tracks the peak total stake. ^m_total_staked in the m_peak_staked transform uses the total after the new stake is added:

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

        unstake()
            where m_stakes[msg::sender] > 0 : throw 101
        => [
            ~> msg::sender with { value: m_stakes[msg::sender] }
        ]
    }

    m_stakes: HashMap<address, U256> {
        in stake() => m_stakes.insert(msg::sender, m_stakes[msg::sender] + msg::value)
        in unstake() => m_stakes.insert(msg::sender, 0)
    }

    m_total_staked: U256 {
        in stake() => m_total_staked + msg::value
        in unstake() => m_total_staked - m_stakes[msg::sender]
    }

    m_peak_staked: U256 {
        in stake() => {
            if ^m_total_staked > m_peak_staked { ^m_total_staked } else { m_peak_staked }
        }
    }
}

unstake pays out a stake that its own transforms reset to zero. Storage is written first, but the argument of an outbound send reads the member as it was before the route, so the transfer carries the old stake (see Actions and State).

Rules

  1. ^m_x is allowed only inside member transform bodies. If m_x has no transform for the current route, ^m_x is simply m_x.
  2. Circular temporal dependencies (where ^m_a depends on ^m_b which depends on ^m_a, or a member reads its own ^ value) are rejected by the compiler.
  3. Temporal references are resolved statically at compile time – there is no runtime overhead.
  4. ^ applies to a whole member or to an expression over it (^m_total, ^m_items.len(), ^m_balances[k] for the new entry at k).

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
View routes cannot emitE29Rejected on EVM (Solidity forbids emit in a view function)

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 {
        #[factory_only]
        init setup(owner: address) => []

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

        disabled()
            from m_owner : throw Unauthorized()
        => [
            throw Unauthorized()
        ]

        accept receive() => []
    }

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

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

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 {
        #[factory_only]
        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: sending a route that the stub does not declare is an error (V23). 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 larger(a: u64, b: u64) -> u64 {
    if a >= b { a } else { b }
}

pure fn smaller(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 } }
}

A pure fn may share a name with a standard-library function (min, max, pow, …). A bare call such as min(a, b) then calls your function, while std::math::min(a, b) always calls the builtin.

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_fee(amount: U256, bps: U256) -> U256 {
    amount * bps / 10000
}

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

entity DEX {
    const FEE_BPS: U256 = 30

    routes {
        view quote(amount_in: U256) -> U256
            where amount_in > 0 : throw 100
        => [
            let out = compute_output(amount_in, m_reserve_a, m_reserve_b, FEE_BPS);
            return(out)
        ]

        add_liquidity(a: U256, b: U256) => []
    }

    m_reserve_a: U256 {
        in add_liquidity(a, _) => m_reserve_a + a
    }

    m_reserve_b: U256 {
        in add_liquidity(_, b) => m_reserve_b + 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. Like any pure fn, library functions have no where clause; put preconditions on the calling route.

library FeeMath {
    const BPS: U256 = 10000

    pure fn fee(amount: U256, rate_bps: U256) -> U256 {
        amount * rate_bps / BPS
    }

    pure fn net(amount: U256, rate_bps: U256) -> U256 {
        amount - fee(amount, rate_bps)
    }
}

using FeeMath for U256;

entity Vault {
    routes {
        deposit(amount: U256) => []
    }

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

    m_fees: U256 {
        in deposit(amount) => m_fees + FeeMath.fee(amount, 30)
    }
}

Call library functions with a qualifier (FeeMath.fee(amount, 30)), or as methods attached via using (amount.net(30), below).

using Lib for T

using FeeMath for U256; in the example above attaches every suitable function from the library as a method on U256, so amount.net(30) means FeeMath.net(amount, 30).

recv.fn(args) rewrites to Lib.fn(recv, args) (or the free pure fn form) before codegen. Chained receiver calls such as amount.net(30).net(10) work when each step’s return type matches the next receiver, and a chain may also start from a qualified call (FeeMath.fee(a, b).net(10)).

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.
  • When the for type is Vec, HashMap, or Option, method names must not collide with that type’s built-in methods (len, push, insert, exists, …).
  • Libraries and free pure fns may live in imported .cam files. See Program Structure and the Contract Standard Library.

Lowering

On EVM, 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.

On Lean, a library is flattened into ordinary pure functions named Lib_fn (a library const becomes a zero-argument function), and every qualified or using call is rewritten to call them.

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(params) -> ReturnType = {
    body_expression
}

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

Basic Examples

enum Status { Active, Paused }

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

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

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

    m_owner: address {}
    m_balance: U256 {}
    m_status: Status {}
}

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.

Give each boolean macro its own where clause, as above. Combining several macro calls in one boolean expression (@is_owner() && @is_funded(amount)) does not yet compile on EVM.

Parameters

Macros can accept parameters:

macro has_allowance(owner: address, spender: address, amount: U256) -> bool = {
    m_allowances[owner][spender] >= 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[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[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, as net_amount does with @fee_for above.

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

Membership maps use HashMap<address, bool>: a missing key reads as false.

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

    macro has_enough_confirmations(tx_id: u64) -> bool = {
        m_confirmations[tx_id] >= m_required
    }

    macro is_pending(tx_id: u64) -> bool = {
        tx_id < m_next_id && m_executed[tx_id] == false
    }

    routes {
        #[factory_only]
        init setup(first_signer: address, required: u64) => []

        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_to[tx_id] with { value: m_amount[tx_id] }
        ]
    }

    m_signers: HashMap<address, bool> {
        in setup(first_signer, _) => m_signers.insert(first_signer, true)
    }

    m_required: u64 {
        in setup(_, required) => required
    }

    m_next_id: u64 {
        in submit(_, _) => m_next_id + 1
    }

    m_to: HashMap<u64, address> {
        in submit(to, _) => m_to.insert(m_next_id, to)
    }

    m_amount: HashMap<u64, U256> {
        in submit(_, amount) => m_amount.insert(m_next_id, amount)
    }

    m_confirmations: HashMap<u64, u64> {
        in confirm(tx_id) => m_confirmations.insert(tx_id, m_confirmations[tx_id] + 1)
    }

    m_executed: HashMap<u64, bool> {
        in execute(tx_id) => m_executed.insert(tx_id, true)
    }
}

Standard Library

Standard 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) unless you declared a pure fn of that name. 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 (u8…u128, i8…i128, U256) where the operation is defined. Unsupported combinations fail at transpile time.

A user pure fn may have the same name as a std function (min, max, pow, …): a bare min(a, b) then calls your function, and std::math::min(a, b) always calls the builtin. std::math::muldivmod is EVM-only (no Lean lowering).

On EVM, call the pair-returning functions (divmod, minmax, muldivmod) and the signed helpers (abs, sign) from a pure fn body. In a route body, let (q, r) = std::math::divmod(x, y); is rejected, and a let bound to abs / sign does not yet compile:

pure fn split(x: U256, y: U256) -> (U256, U256) {
    let (q, r) = std::math::divmod(x, y);
    (q, r)
}

std::str

Parsing

std::str::parse_u64(s: String, radix: u64) -> Option<u64>
std::str::parse_u8 / parse_u16 / parse_u32 / parse_u128 / parse_U256
std::str::parse_i8 / parse_i16 / parse_i32 / parse_i64 / parse_i128
std::str::parse_uint(s, radix) -> Option<u64>   // alias of parse_u64
std::str::parse_int(s, radix) -> Option<i64>    // alias of parse_i64

Each parse_<T> returns Option<T>.

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.

let n = match std::str::parse_u64(s, 10) {
    some(v) => v,
    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 or String) -> bytesSHA-256 digest (32 bytes)
view digest(data: bytes) -> bytes => [
    return(std::crypto::sha256(data))
]

std::crypto::sha256 currently lowers only on EVM; it has no Lean lowering (L13). On EVM, evm::sha256 is also 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::format → V50

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
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.

math.cam deliberately does not define min / max: imported names share one program-wide namespace, so a stdlib min would collide with any project that already declares its own.

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. stdlib/token/project.lean.yaml builds the token sources for the Lean target.

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 loweringLean
msg::senderaddressCaller of this callmsg.senderyes
msg::valueU256Native currency attached to the callmsg.valueyes

These are the fields you should rely on for access control and payable logic. An unknown msg:: field is rejected.

Optional time alias

FieldNotes
msg::timestampLowers to block.timestamp on EVM and is modelled on Lean. 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 @is_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() => {
        let current = m_balances[msg::sender];
        m_balances.insert(msg::sender, current + msg::value)
    }
}

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 loweringLean
sys::now / sys::timestampBlock / host timestampblock.timestampyes
sys::block_numberBlock numberblock.numberyes
sys::chainidChain idblock.chainidyes
sys::addressThis entity’s addressaddress(this)yes
sys::balanceThis entity’s native balanceaddress(this).balanceyes
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—

Fields marked “—” are EVM-only: the Lean model has no counterpart, and they are rejected when compiling for Lean.

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 the EVM target. Only the seven intrinsics below exist; any other evm:: name is rejected. The Lean model covers just evm::keccak256Packed (1–4 arguments) and evm::ecrecover. evm::sha256, evm::ripemd160, evm::balance, and evm::blockhash are rejected when compiling for Lean, and evm::keccak256 is EVM-only as well.

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::keccak256(x)U256uint256(keccak256(abi.encode(x)))
evm::keccak256Packed(a, b, ...)U256uint256(keccak256(abi.encodePacked(a, b, ...)))
evm::sha256(a, ...)U256uint256(sha256(abi.encodePacked(a, ...)))
evm::ripemd160(a, ...)U256ripemd160(abi.encodePacked(a, ...)), widened to uint256

evm::keccak256 is not yet accepted inside a pure fn; call it from a route or member transform.

Impure intrinsics

Read live account or block context — not allowed inside pure fn (V4):

IntrinsicReturnsLowers to
evm::balance(addr)U256addr.balance
evm::blockhash(n)U256uint256(blockhash(n))

Example (signed permit)

entity Permits {
    error Expired();
    error BadSignature();

    routes {
        permit(owner: address, value: U256, deadline: U256, v: u8, r: bytes32, s: bytes32)
            where sys::timestamp <= deadline : throw Expired()
            && evm::ecrecover(
                evm::keccak256Packed(owner, value, deadline, m_nonces[owner]),
                v, r, s
            ) == owner : throw BadSignature()
        => []
    }

    m_nonces: HashMap<address, U256> {
        in permit(owner, _, _, _, _, _) => m_nonces.insert(owner, m_nonces[owner] + 1)
    }
}

ecrecover accepts the U256 digest directly (it is cast to bytes32 in the generated Solidity). A production permit would hash an EIP-712 domain and struct; the shape of the check is the same.

Portable digests

For a digest that must mean the same thing on EVM and in the Lean model, use evm::keccak256Packed. evm::keccak256, evm::sha256, and std::crypto::sha256 are EVM-only.

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

The target and output directory come from the YAML; -o and --target are ignored in project mode (with a warning).

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
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/)
foundryFoundry / solc options (optimizer, via_ir, remappings, …)
fuzzShared fuzz defaults (runs, seed, …)
invariantShared invariant defaults (runs, depth, fail_on_revert, …)
leanLean-target knobs (numerics, proof_helpers, intrinsics, …)

Unknown keys (and fuzz.shrink, which is accepted but has no effect) produce a warning and are otherwise ignored.

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

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 options), see the reference page project.yaml.

Phased Routes

Most routes are unphased: every member transform runs as one atomic step, then every action (~>, deploy, emit, …) runs afterward. That covers the common case and matches the checks–effects–interactions (CEI) discipline on EVM. Arguments of sends, call, and deploy still carry the values from before the transforms, so an unphased route can reset a balance and pay out the old amount.

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.
  3. Snapshot before overwrite — a let, if condition, emit, or return needs a member’s old value (those actions read the values after all transforms; only effect arguments read the old ones).

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 : [
    ]
]

(m_token is an Address<Token> member; a var capture needs a typed destination.)

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 …
Bind (log, branch on) a value the route resetssnap: with let x = m_…;, then pay: with the transform and the actions
Optimistic transfer, then callback, then checkThree phases (see Uniswap V2 swap)
Effect between two groups of transformsExplicit empty phases (tag: []) so transforms can attach

The snapshot pattern — snap only binds, pay writes storage and then sends, so it also keeps checks-effects-interactions order. Here the old amount is needed by the emit as well as the send, so it goes through a let bound before the reset:

entity Payouts {
    event Claimed(indexed who: address, amount: U256);

    routes {
        claim()
            where m_pending[msg::sender] > 0 : throw 1
        => [
            snap: [
                let amount = m_pending[msg::sender];
            ]
            pay: [
                ~> msg::sender with { value: amount }
                emit Claimed(msg::sender, amount);
            ]
        ]
    }

    m_pending: HashMap<address, U256> {
        in claim() => pay: m_pending.insert(msg::sender, 0)
    }
}

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 the actions of Pᵢ
       (arguments of sends, `var` captures, `call` and `deploy` see
        state_{Pᵢ₋₁}; `let`, `emit`, `if` conditions and `return` see state_Pᵢ)

Members without a transform at Pᵢ keep their value from Pᵢ₋₁. Storage is written before the phase’s external calls are made, yet those calls carry the values the phase started with — so a phase can reset a balance and pay out the old amount in one step. A value bound with let is read after the writes; to carry an old value through a let, bind it in an earlier phase. An unphased route behaves like a single phase.

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 swap() => p1: m_b }
m_b: u64 { in swap() => 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;
]

As with any named send, the destination must be typed (m_token is an Address<Token> here). 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 step() => p1: m_b + 1 }
m_b: u64 { in step() => p1: m_a + 1 }
m_c: u64 { in step() => 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ᵢ
Send / call / deploy argument in PᵢPre-PᵢNot available in actions
let, emit, if condition, return in PᵢPost-PᵢNot available in actions

Temporal ^ is a transform-only construct. In actions, the arguments of outbound effects read the state as it stood when the phase began (so a phase can pay out a balance its own transforms reset), while every other action reads the post-transform state of its phase. See Execution Model.

Identity Members

Identity members are immutable fields fixed at deploy time. On EVM 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 {
        #[factory_only]
        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) on EVM 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:

pure fn min_addr(a: address, b: address) -> address {
    if a < b { a } else { b }
}

pure fn max_addr(a: address, b: address) -> address {
    if a < b { b } else { a }
}

entity UniswapV2Pair {
    identity m_token0: address
    identity m_token1: address

    routes {
        #[factory_only]
        constructor() => []
        sync() => []
    }

    m_syncs: u64 {
        in sync() => m_syncs + 1
    }
}

entity UniswapV2Factory {
    // no identity — singleton
    routes {
        #[factory_only]
        constructor(fee_to_setter: address) => []

        createPair(tokenA: address, tokenB: address)
            where tokenA != tokenB : throw 1
        => [
            deploy UniswapV2Pair(min_addr(tokenA, tokenB), max_addr(tokenA, tokenB))
        ]

        view pairFor(tokenA: address, tokenB: address) -> address => [
            return(UniswapV2Pair.address(min_addr(tokenA, tokenB), max_addr(tokenA, tokenB)))
        ]
    }

    m_fee_to_setter: address {
        in constructor(fee_to_setter) => fee_to_setter
    }
}

Elsewhere, the factory’s own address is 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

On EVM, 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
output_dir: build/
sources:
  - det_guardian.cam
  - det_locker.cam

CREATE2 and CambrianFactory

The EVM backend emits a project-local CambrianFactory. Every entity is deployed by it with CREATE2, so an address is derived from:

  • the factory’s own address,
  • a fixed salt of 0,
  • the entity’s creation code plus its constructor arguments — the factory address followed by the identity values.

The factory address is not known when code is generated. Instead, each entity stores the factory that deployed it (an immutable _factory) and asks it for predictions: Entity.address(args) lowers to a call to the factory’s generated predict<Entity>(args). Every entity deployed by the same factory therefore agrees on every other entity’s address, computed from identity fields alone.

Authors never write a custom factory.

Entity.address(args) and addressOf

Prefer the dot form. The addressOf(Entity.state(args)) spelling exists as well; both lower to the same CREATE2 prediction:

acceptPing() ~> Locker.address(locker_id)
acceptPing() ~> addressOf(Locker.state(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)

On EVM, 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. from Entity(args) arity must match the target entity’s identity-member count (V33).

deploy Entity(…)

deploy goes through CambrianFactory:

  1. CREATE2-deploy with the factory address and 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(Entity.state(args))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.

Mixed-sign arithmetic lowers on both backends in member transforms and pure fn bodies. On EVM, a mixed-sign expression directly in a route body (a let value or return(...)) does not yet compile; compute it in a pure fn and call that from the route.

Rule 3 — u128 / U256 mixed with signed

There is no wider signed primitive than i128, so mixing u128 or U256 with any signed type in arithmetic has no common type and is rejected. Cast one side explicitly with as. (Comparisons are exact and allowed for u128; only U256 against a signed operand is rejected there.)

m_a: u128 + d: i8    // rejected
m_b: U256 + d: i32   // rejected

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 marks a rejected arithmetic combination (Rule 3).

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 — larger than most balance fields. On EVM, keep mixed-sign expressions out of route bodies (see Rule 2).
  • 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 require revert (empty revert data)

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

entity Counter8 {
    routes {
        tick() => []
        untick() => []
        double() => []
    }

    m_ring: u8 {
        in tick() => m_ring +% 1      // 255 -> 0
        in untick() => m_ring -% 1    // 0 -> 255
        in double() => m_ring *% 2    // 255 -> 254
    }
}

Signed wrapping is two’s complement (127 +% 1 is -128 for i8).

There is no /% or %%.

EVM lowering

On --target evm, wrapping ops lower to pure helpers _wadd / _wsub / _wmul (and _wadds / _wsubs / _wmuls for signed operands) whose bodies are unchecked { … }. That preserves Solidity 0.8 wrap-around without turning off checked arithmetic elsewhere in the contract. The helpers are emitted inside the contract for entity code, and at file scope when a free pure fn uses a wrapping op.

The helpers take uint256 / int256; a narrower result is truncated with an explicit Solidity cast (so u8 wrap-around is the low 8 bits, and i8 wraps within -128…127).

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 +% -% *%
u8–u128yesyes
i8–i128yesyes
U256yesyes

Both kinds work in member transforms, route bodies, and pure fn bodies, on EVM and Lean. In the BitVec Lean modes, a wrapping result is truncated to the width of its type, as on EVM.

Interaction with widening

Overflow checking (or wrapping) applies to the result type after widening, which depends only on the operand types — not on where the result is stored. a + b with a, b: u8 is computed in u8 and reverts if the sum exceeds 255, even when assigned to a u16. Cast an operand first ((a as u16) + b) when you want the full mathematical result.

Mixing operators

Each operator decides independently:

entity Mixer {
    routes {
        mix(b: U256, c: U256) => []
    }

    m_acc: U256 {
        in mix(b, c) => (m_acc +% 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> -o <dir> --target evm|lean
cambrian-transpiler --project project.yaml   # target and output_dir come from the YAML

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–L16).

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 is ignored with a warning.

Capability matrix

Capabilityevmlean
Models EVM (CREATE2, payable, balances, …)yesyes
evm::* intrinsics, msg:: / sys:: fieldsallsubset (see System Context, EVM Intrinsics)
Desugars property → test / fuzzyesno (keeps theorems)
Multi-entity invariantsyesyes
Primary test oracleforge testlake build

Choosing a target

  • Ship on Ethereum → evm (Foundry / solc via forge).
  • Prove EVM-domain properties → lean (same semantics, theorem statements; main theorems try an automatic proof and fall back to 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 CREATE2 addresses (every entity is deployed through the generated CambrianFactory).

Output layout

Single file, entity only:

out/
  .cambrian-manifest.json  # generated files + SHA-256 hashes
  src/Counter.sol          # entity contract + CambrianFactory

Single file with test / property / invariant blocks, or a multi-file project (project.yaml):

out/
  .cambrian-manifest.json
  src/_<project>_project.sol   # combined program (+ CambrianFactory)
  src/<Entity>.sol             # thin import stubs per entity
  test/…                       # Foundry tests
  foundry.toml
  setup.sh                     # installs forge-std (forge install --no-git)
cambrian-transpiler --project project.yaml   # output_dir comes from the YAML
cd <output_dir> && bash setup.sh && 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

On EVM:

  • The backend emits a CambrianFactory (and ICambrianFactory) with one deploy<Entity>(…) / predict<Entity>(…) pair per entity; deploys use CREATE2. Every entity constructor takes the factory address.
  • An init route with parameters splits into a deploy-time constructor stub plus a factory-guarded initialize(…), which deploy<Entity> calls. Mark the init route #[factory_only] and pass recipients (owner, mint beneficiary) as explicit parameters — under the factory, msg::sender in the init route is the factory.
  • Entity.address(args) and addressOf(Entity.state(args)) 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.

from Entity(args) arity must match the target entity’s identity-member count (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 (and the factory’s deploy<Entity>) are payable by default so deploy … with { value: … } works. Set evm.allow_constructor_payable: false in project.yaml to make them non-payable (the factory then deploys with value: 0).

Events and errors (ABI)

Cambrian event / error declarations become Solidity ABI items:

event Transfer(indexed src: address, indexed dst: address, amount: U256);
error InsufficientBalance(have: U256, need: U256);

entity Wallet {
    routes {
        deposit(amount: U256) => []
        withdraw(amount: U256)
            where (m_balance >= amount) : throw InsufficientBalance(m_balance, amount)
        => [
            emit Transfer(msg::sender, msg::sender, amount);
        ]
    }

    m_balance: U256 {
        in deposit(amount) => m_balance + amount
        in withdraw(amount) => m_balance - 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
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          # simp attribute sets
    Core.lean               # vendored domain-free foundations
    Evm.lean                # vendored EVM domain (MsgCtx, SysCtx, WorldState)
    Prelude.lean            # facade importing SimpAttrs, Core, Evm
    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; on other targets it is ignored with 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. Main theorems get a best-effort automatic proof that falls back to sorry when it does not close the goal:

theorem increment_adds_to_count : … := by
  first
    | (simp [cambrian_route_simp, …]; done)
    | (simp [cambrian_route_simp, …, cambrian_bitvec_simp] <;> omega)
    | sorry
PlacementPolicy
Main theorems / lemmas in *Spec.leanAutomatic simp / omega attempt; a fallback sorry 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
L13–L14Missing Lean lowering for std::crypto::* / unresolved type identifiers
L15HashMap member-transform body shape not recognised (use insert / update / remove)
L16Namespaced call (std::…, evm::…) or msg:: / sys:: field with no Lean lowering

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 }
}

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
}

A property with no nested test / fuzz instance still gets one default fuzz case on EVM (here m_count: * becomes a fuzzed argument).

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; on properties and invariants msg:: / sys:: live in ctx { … } (T22). Plain tests set context per step with msg { … } / sys { … }.
  • 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; expect emit goes before the call it observes. 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 / msg / sys
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)
let name = expr / let name: T = exprBinding visible for the rest of the test; an untyped let takes its type from its uses (a literal used as a sender, address parameter, or map key is an address)
msg { sender: …, value: … } / sys { timestamp: …, … }Message / system context for the following calls
call route(args)Invoke a route (arity must match)
expect state { … }Assert members after a call
expect throw NAssert the call reverts with numeric code N
expect emit Event(args)Assert the next call emits Event(args) (place it before the call)
expect return EAssert return payload equals E (route must declare -> T)
expect return == ESame equality assertion as expect return E
expect <bool>Assert an arbitrary boolean after the call
expect effects […]Assert outgoing effects (backend-dependent; prefer state/return on EVM)

Plain tests have no ctx { … } block; that belongs to properties and invariants. On Lean, every expect after a call is kept and conjoined in the theorem goal.

test "withdraw emits" for Wallet with { m_balance: 10 } {
    let alice = 0x0000000000000000000000000000000000000001
    msg { sender: alice }
    expect emit Withdrawn(alice, 4)
    call withdraw(4)
    expect state { m_balance: 6 }
}

The event may be declared at program scope or inside the entity.

Lenses

Return values and member reads can be compared directly or through a field / index path:

expect return == (0, 5)
expect return != 0
expect return > amount
expect return.reserve_a > 0

expect m_balances[alice] == 900
expect m_state == Status::Funded

expect return E and expect return == E compare for equality. !=, <, <=, >, and >= (including on return.field) are boolean predicates, as is any other boolean expression:

call pay(bob, 30)
expect m_spent + m_balance == start
call left()
expect return > 0 && m_balance >= 70

expect state also takes field paths and map keys, and return values take tuple / length lenses; these run on both EVM and Lean:

expect state { m_pool.reserve_a: 1500 }
expect state { m_balances[alice]: 900 }
expect state { m_state: Status::Funded, m_opt: some(3) }
expect return.0 == 0
expect return.len == 3

On EVM, expect state reads members through their public Solidity getters. A Vec member (or a record or map holding a Vec / HashMap) has none, so expect state on it is rejected (T39); expose it through a view route and assert with expect return instead.

A bare member name is the post-state field after that call. Bind the starting value with with { m_balance: start } or a parameter. expect state { … } stays a map of equalities.

When the assertion does not start with the return keyword, the preceding call’s return value is the name result. That name is reserved in this expect: a parameter, let, or member also called result makes the predicate T38 if it mentions result. A non-boolean shape (tuple, record, array, empty collection) is also T38. expect before any call is still T5.

A whole-map literal (expect state { m_balances: { alice => 900 } }) checks each listed key on EVM, but does not yet build on Lean; for tests that must run on both targets, check maps key by key (expect 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(…)).

with vs context (brief)

  • with / state pins — entity members only (m_count: 0). A msg:: / sys:: key inside a test’s with is a parse error.
  • msg { … } / sys { … } steps — message and system context in plain tests (msg { sender: alice, value: 100 }, sys { timestamp: 100 }); they apply to the calls that follow.

Properties and invariants use a separate ctx { msg::sender: …, sys::now: … } block instead (T22 rejects context keys in their with / init). 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 }
}

An instance may also carry its own with { … } to override the property’s initial state — the body’s expectations must then still hold from that state.

Boolean expect

A postcondition that is not equality is a boolean expect. The expression is ordinary Cambrian (comparisons, && / || / !, arithmetic, field and index access). It is legal in property, test, and fuzz bodies.

entity Purse {
    routes {
        spend(to: address, amount: U256) -> U256
            where (m_balance >= amount) : throw 1
        => [
            return(m_balance)
        ]
    }

    m_balance: U256 {
        in spend(to, amount) => m_balance - amount
    }

    m_spent: U256 {
        in spend(to, amount) => m_spent + amount
    }
}

property "spend moves funds" (to: address, amount: U256, start: U256) for Purse with { m_balance: start } {
    assume amount > 0 && start >= amount
    call spend(to, amount)
    expect return == start - amount
    expect m_balance <= start
    expect m_spent + m_balance == start
    expect return > 0 || amount == start
}
  • expect return E and expect return == E stay equality assertions. expect return.field == … stays an equality lens. Relational operators are predicates.
  • A bare member name is the post-state field after that call. with { m_balance: start } (or a parameter) names the starting value. with { m_balance: * } still quantifies the initial value, but m_balance inside the predicate is the post-state field.
  • Elsewhere in the predicate, the preceding call’s return value is the name result. If a parameter, let, or member is also called result and the predicate mentions it, validation reports T38.
  • expect state { … } stays a list of equalities.

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 forall markers, 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 with → T22.

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
  max_local_rejects: 1024

Threaded into Foundry [profile.default.fuzz]. (fuzz.shrink is accepted but has no effect; the compiler warns about it.)

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 { 0x0000000000000000000000000000000000000000000000000000000000000aa1,
              0x0000000000000000000000000000000000000000000000000000000000000bb2 }

    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. Addresses may be written as 40- or 64-hex-digit literals
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

For a Vault with deposit(amount: u64) / withdraw(amount: u64) routes and an m_balance: u64 member:

invariant "deposits lead withdrawals" for Vault
    #[depth(10)]
{
    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).

Keep assume-excluded steps satisfiable for the whole trace: an assume trace::length < N with a trace depth larger than N (the generated foundry.toml uses depth 50) eventually rejects every action and Foundry aborts with “vm.assume rejected too many inputs”. Match the depth with #[depth(N)] as above.

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

With Vault carrying identity m_vault_id: u64 and an m_balance: u64 member, and Treasury an m_total: u64 member (as in contracts/escrow_two_vaults.cam):

invariant "vault ids stay distinct" for { v: Vault, a: Vault, t: Treasury } {
    init v { m_vault_id: 1, m_balance: 0 }
    init a { m_vault_id: 2, 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_vault_id != a.m_vault_id
    check v.m_balance + a.m_balance >= 0
    check t.m_total >= 0
}

Actions and member refs must be qualified (I9/I11). Two or more instances of the same entity need identity members pinned to distinct values in their init blocks — each instance is a separate CREATE2 deployment (I18). 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.
  • invariant → one file test/Invariant_<Entity>_<name>.t.sol with a <Entity>Handler_<name> handler and an Invariant_<Entity>_<name>_Test suite (targetContract / targetSelector / targetSender). Multi-entity invariants drop the entity part: Handler_<name> / Invariant_<name>_Test.
  • Profiles: default, cambrian (CI), cambrian_night (stress).
cambrian-transpiler --project project.yaml   # output_dir comes from the YAML
cd <output_dir> && bash setup.sh
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 theorems get an automatic simp / omega attempt with a sorry fallback; 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
u8…u128, i8…i128, U256, bool, addressyes
bytes, bytes32yes
Stringfuzzed, but warns W7

Other types (records, collections, …) warn (W7) instead of hard-failing; the harness is still emitted but may degrade.

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 next to counter.cam under sources: in a project.yaml):

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

To include the companion tests, use a project.yaml as in Compile and Test.

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 {
    const ZERO: address = 0x0000000000000000000000000000000000000000

    event Transfer(indexed src: address, indexed dst: address, value: U256);
    event Approval(indexed owner: address, indexed spender: address, value: U256);

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

    routes {
        #[factory_only]
        constructor(holder: address, initial_supply: U256) => [
            emit Transfer(ZERO, holder, initial_supply);
        ]

        transfer(to: address, amount: U256)
            where amount > 0 : throw ZeroAmount() &&
                  balance_of(m_balances, msg::sender) >= amount
                      : throw InsufficientBalance(balance_of(m_balances, msg::sender), amount)
        => [
            emit Transfer(msg::sender, to, amount);
        ]

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

        view balanceOf(owner: address) -> U256 => [
            return(balance_of(m_balances, owner))
        ]

        view totalSupply() -> U256 => [
            return(m_total_supply)
        ]
    }

    m_total_supply: U256 {
        in constructor(_, initial_supply) => initial_supply
    }
    // m_balances and m_allowances below…
}

where / throw Name(…) pair with declared errors; emit pairs with declared events. Up to three indexed fields per event on EVM (V36). The balance check lives in where, which runs before any member transform; a let or emit in the route body would already see the updated map.

The initial holder is an explicit constructor parameter and the constructor is #[factory_only]: on EVM the token is deployed through the generated CambrianFactory, so msg::sender inside the constructor would be the factory, not the deployer.

HashMap balances

m_balances: HashMap<address, U256> {
    in constructor(holder, initial_supply) => {
        {}.insert(holder, initial_supply)
    }
    in transfer(to, amount) => {
        let sender = msg::sender;
        let sender_bal = balance_of(m_balances, sender);
        let to_bal = balance_of(m_balances, to);
        if to == sender { m_balances } else {
            m_balances
                .update(sender, sender_bal - amount)
                .update(to, to_bal + amount)
        }
    }
}

Maps are persistent values: .insert / .update return a new map; the transform’s final expression becomes the stored member. Empty map literal: {}. Both balances are read before either is written, so a self-transfer (to == sender) must return the map unchanged — otherwise the credit would overwrite the debit and mint amount out of nothing.

Nested allowances

m_allowances: HashMap<address, HashMap<address, U256>> {
    in approve(spender, amount) => {
        let sender = msg::sender;
        let inner = if m_allowances.exists(sender) {
            m_allowances[sender]
        } else {
            {}
        };
        m_allowances.update(sender, inner.update(spender, amount))
    }
}

transferFrom decrements the inner map the same way (see contracts/token.cam for the full mint / burn / allowance path).

Patterns worth copying

PatternWhere
pure fn for map lookups used in where and transformsbalance_of
Named error + throw Error(…)erc20_errors_evm.cam
event + emiterc20_events_evm.cam
Unphased CEI: update maps, then emittransfer body above

For a production-shaped ERC-20 with EIP-2612 permit and optional multi-instance identity, use the shipped Contract Standard Library (stdlib/token/ERC20.cam / ERC20Multi.cam). The Uniswap V2 example keeps its own pair token in examples/uniswap-v2/ERC20.cam.

Tests in the workspace

FileRole
contracts/token.cam + contracts/token.test.camBaseline entity (numeric throws, no events) and its unit tests
contracts/erc20_events_evm.camEvent / emit patterns
contracts/erc20_errors_evm.camNamed errors

contracts/token.cam follows the factory rules like the merged Token above: its #[factory_only] constructor takes the owner as an explicit first parameter, constructor(owner, name, symbol, decimals, initial_supply). List token.cam and token.test.cam under sources: in a project.yaml and build it the same way as Counter: with target: evm the 12 unit tests pass under forge test, with target: lean the theorems build with lake build.

The two erc20_*_evm.cam fixtures predate factory deployment: their constructors read msg::sender and carry no #[factory_only], so EVM project mode rejects them (single-file mode only warns). Read them for the event and error patterns.

Escrow

Two-party escrow with an arbiter and a deadline. Prefer the enum-based design of contracts/escrow_v2.cam (shown below); 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 {
        #[factory_only]
        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.

One difference from the repo file: the constructor is #[factory_only], which EVM projects require (the entity is deployed through the generated CambrianFactory).

Why enums

escrow.cam (u8)escrow_v2.cam (enum)
State typemagic numbersState::Funded
Queriesreturn raw u8match to a code if needed
Extra state—Option<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

The escrow fixtures’ constructors carry no #[factory_only], so EVM project mode rejects them. Single-file mode only warns, so their unit tests run under Foundry directly:

cambrian-transpiler contracts/escrow_v2.cam contracts/escrow_v2.test.cam -o /tmp/escrow-evm --target evm
cd /tmp/escrow-evm && bash setup.sh && forge test

Both escrows and their tests also build as target: lean projects. With #[factory_only] added as in the excerpt above, escrow_v2.cam and its tests run in an EVM project as well. contracts/escrow_two_vaults.cam + its invariant run in both EVM and Lean projects — see Counter for the project.yaml workflow.

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
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 {
        #[factory_only]
        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 {
        #[factory_only]
        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 == ICambrianFactory(_factory).predictGuardian(m_id)) on EVM (a CREATE2 prediction) — sender type and identity in one clause.

Both constructors are #[factory_only]: every instance, including children created by deploy, is installed by the generated CambrianFactory.

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
  project.lean.yaml
  ERC20Votes.cam            (+ .test / .fuzz / .invariant / .multi.invariant)
  TimelockController.cam    (+ .test / .invariant)
  Governor.cam              (+ .test / .fuzz / .invariant / .multi.invariant)
  ref/                      # vendored OZ reference
  build/                    # generated EVM project (gitignored)
  build-lean/               # generated Lean project (gitignored)

The *.multi.invariant.cam files hold multi-entity invariants and are listed only in project.lean.yaml.

Entities

EntityShapeRole
ERC20VotesToken + votesgetVotes / transfers; feed for castVote
TimelockControllerSingletonSchedule / execute / cancel with role from
GovernorSingletonPropose → vote → queue → execute

Cross-contract handles use typed addresses:

#[factory_only]
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(target: address, value: U256, data: CamData,
         predecessor: U256, salt: U256, delay: u64)
    from m_proposer : throw 1
    where delay >= m_min_delay : throw 2
=> []

execute(target: address, value: U256, data: CamData,
        predecessor: U256, salt: U256)
    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.

Enums — ProposalState / 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, output in build-lean/):
cambrian-transpiler --project examples/governor/project.lean.yaml
cd examples/governor/build-lean
lake build

The transpiler prints warnings while generating (among them an F7 note that fuzz.shrink in project.yaml has no effect); they don’t stop the build. The Foundry run passes all unit, fuzz and invariant suites.

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              # target: evm, via_ir
  project.lean.yaml
  shared.cam                # imported pure helpers (min/max/mul_div)
  ERC20.cam                 (+ .test / .fuzz / .invariant / .multi.invariant)
  UniswapV2Pair.cam         (+ .test / .fuzz / .invariant)
  UniswapV2Factory.cam      (+ .test / .invariant / .multi.invariant)
  ref/                      # upstream V2 + FlashCallback
  build/                    # generated EVM project
  build-lean/               # generated Lean project

The *.multi.invariant.cam files hold multi-entity invariants and are listed only in project.lean.yaml.

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_addr(tokenA, tokenB), max_addr(tokenA, tokenB)) (min_addr / max_addr are pure fns in UniswapV2Factory.cam).

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 permit — evm::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. As with Governor, the only transpiler warning about the YAML is an F7 note that fuzz.shrink has no effect. Lean:

cambrian-transpiler --project examples/uniswap-v2/project.lean.yaml
cd examples/uniswap-v2/build-lean && lake build

Details and the closed gap log (G-U1…G-U9) 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. A std:: name outside the functions listed here is rejected on both targets (E25).

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 (muldivmod returns quotient and remainder; EVM only — no Lean lowering, L16)
divmod, divc, divrDivision variants
modpow2, powPowers

std::str

FunctionRole
parse_u8, parse_u16, parse_u32, parse_u64, parse_u128, parse_U256(s: String, radix) -> Option<uN>
parse_i8, parse_i16, parse_i32, parse_i64, parse_i128(s: String, radix) -> Option<iN>
parse_uint / parse_intAliases of parse_u64 / parse_i64
formatRust-style format string: std::str::format("{:06}", x)

A parse returns none on an empty string, an invalid digit, or a bad radix.

String ↔ number conversion is never implicit (V50): use parse_* / format.

pure fn parse_or_zero(s: String) -> u64 {
    match std::str::parse_u64(s, 10) {
        some(n) => n,
        none => 0,
    }
}

std::crypto

FunctionRole
sha256SHA-256 digest of bytes / String. EVM only — no Lean lowering (L13)

A user pure fn may reuse a std:: function name (min, pow, …): a bare min(a, b) then calls the user function, while std::math::min(a, b) always calls the standard library. V49 fires on a bare call only when no user function of that name exists.

NamespaceDocs
msg::Message Context
sys::System Context
evm::EVM Intrinsics

Keywords

Reserved words and reserved route names in public Cambrian programs. A reserved word can’t be used as an identifier (parameter, route, member, let name, …): rename from → sender, record → log, deploy → setup, and so on.

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
fnSecond word of pure fn
receive / fallbackReserved route names on EVM (Receive and Fallback); not keywords
where / from / throwPreconditions / sender check / failure
returnReturn from a route
callInvoke a private route from an action list; also a test step
deployDeploy another entity
emitEmit an event
event / errorDeclarations
indexedEvent parameter indexing (EVM)
library / using / forLibraries, using Lib for T, expression/action for
const / type / record / enumDeclarations
macroEntity macros (@name())
match / if / elseControl flow
let / varBindings (var for capturing ~> returns)
inMember transforms; for x in …; fuzz ranges p in lo..hi
asCasts (x as u64)
true / falseBoolean literals
some / noneOption constructors
arrayArray literal array(a, b, c)
identityIdentity member marker
withSend / deploy options; test and property initial state
import / useFile import (import "./…" or bare import "token/core.cam") / namespace (use …)
address_ofaddress_of Entity(args) — predicted CREATE2 address
solidity_import@solidity_import("…") on an extern entity
test / property / fuzz / invariantTesting (fuzz { … } is a property instance)
expect / assume / bound / ctx / skipTest / property vocabulary (skip from, skip if)
track / derived / exclude / selectors / advanceTimeInvariant vocabulary
cam_encode / effectsReserved by the test grammar

check, action and senders are invariant keywords but are contextual: they can still be used as ordinary identifiers.

accept has no runtime effect on EVM or Lean (payability is inferred), so it produces warning E05 on a regular route. accept receive() is exempt: there accept is required (V40).

The namespace prefixes msg::, sys:: and trace:: are single tokens; std:: and evm:: are ordinary namespace paths.

Operators such as ~>, ^, =>, &&, ||, +% are listed under Operator Precedence.

Operator Precedence

Expression operators bind as in cambrian-transpiler/src/cambrian.lalrpop (lowest to highest). All listed binary operators are left-associative.

LevelOperatorsNotes
0|params| body / lo..hiClosure / range — only as a whole expression (call argument, for iterator), never inside another operator
1||Logical or
2&&Logical and
3|Bitwise or
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). The target must be a simple type name, not a generic like Vec<u8>
12! - *Unary not, negation, dereference
13.field .method() .0 [index]Postfix access (.0, .1 index a tuple)
14PrimaryLiterals, ^member, @macro(), msg::, sys::, calls

Comparisons bind tighter than the bitwise operators, so a & mask == 0 parses as a & (mask == 0). Parenthesise: (a & mask) == 0.

Ranges

FormWhere
lo..hiExclusive range: for i in 0..n, fuzz bindings p in lo..hi, bound p in lo..hi
lo..=hiInclusive range: only in fuzz instance bindings (fuzz { p in 1..=100 }) and bound p in lo..=hi

Closures

|x| expr, |x| { block }, |x| if c { a } else { b } and |x| Rec { … } are accepted as arguments of iterator methods (.map, .filter, .fold). A closure can’t be stored or returned as a value.

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 / if
~>Send / transfer action
with { … }Send / deploy options after ~>
| in from A(…) : throw E() | B(…) : throw E()Separates from alternatives
&& in where c1 : throw 1 && c2 : throw 2Chains where clauses, each with its own : throw
: in c : throw NAttaches the failure to a where / from clause

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 <INPUT.cam> [MORE.cam ...] [OPTIONS]
cambrian-transpiler --project <project.yaml> [--check] [--check-lean] [OPTIONS]
cambrian-transpiler --version [--json]
cambrian-transpiler --help

Targets

--targetOutput
evm (default)Flat Solidity + Foundry harness
leanLean 4 Lake project

Flags

FlagDescription
-o <dir>Output directory (single-file mode). Default build/<entity_name>-entity/, where <entity_name> is the first entity’s name in snake_case (Counter → build/counter-entity/)
--target <name>Select backend (single-file mode)
--project <file>Load a multi-file project.yaml
--checkParse, validate and run target checks; write no files
--check-leanAfter Lean codegen, run lake build in the output directory
--dump-astParse, print the AST in Cambrian syntax, exit (single-file mode)
--diagnostic-format human|jsonhuman (default) or json: one cambrian.diagnostics/v1 JSON envelope on stdout (compiler identity + diagnostic list); progress lines are suppressed
--max-errors NCap on recovered parse diagnostics (default 32)
--version [--json]Print the compiler build identity (--json: a cambrian.build-identity/v1 object)
--helpPrint usage

--help also lists --source-map; it has no effect on the EVM and Lean targets and only prints a warning.

Single-file vs project mode

  • Single-file mode takes one or more .cam files on the command line (e.g. a contract plus its .test.cam) and merges them. The first entity names the default output directory and, on EVM, the main .sol file.
  • Project mode (--project p.yaml) reads the target, sources and output_dir from the YAML (relative to the YAML file). -o, --target and --dump-ast are ignored, with a warning for each. The init-route rules for factory deploy (V62 / V63) are warnings in single-file EVM mode and errors in project mode, so use --project … --check before shipping.
  • --check-lean with an EVM target has no effect and prints a warning, in both modes.
  • On EVM the Foundry harness (foundry.toml, setup.sh, test/) is emitted only when the program contains tests, properties or invariants.

Exit codes

CodeMeaning
0Success (warnings do not change the exit code)
1Any error: diagnostics with error severity, unknown flag value or target, missing input, failed lake build under --check-lean, or a post-codegen E27 (the generated files are left on disk for inspection)

Examples

cambrian-transpiler contracts/counter.cam -o /tmp/c-evm --target evm
cambrian-transpiler contracts/counter.cam contracts/counter.test.cam -o /tmp/c-evm
cambrian-transpiler contracts/counter.cam -o /tmp/c-lean --target lean --check-lean
cambrian-transpiler contracts/counter.cam --check --diagnostic-format json
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:             # required
  - 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

Top-level keys

KeyTypeNotes
namestringOptional; defaults to the directory name in the --project path (project when the path is a bare file name). Used for the EVM bundle file src/_<name>_project.sol
targetstringRequired — evm or lean
output_dirstringDefault build/, relative to the YAML file
sourceslist of pathsRequired — entry-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.
evmobjectEVM codegen knobs
foundryobjectFoundry / solc settings
fuzzobjectDefault fuzz runs / seed
invariantobjectDefault invariant runs / depth
leanobjectLean emission knobs

Unknown keys, at the top level or inside one of the blocks below, are ignored with an F7 warning (for example project.yaml: unknown key `foundry.solc_versoin` (ignored)), so a misspelt setting shows up in the build output.

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.

evm

KeyDefaultNotes
allow_constructor_payabletrueWhen false, entity constructors are non-payable and the CambrianFactory deploys with value: 0

foundry

These keys are written into the generated foundry.toml.

KeyDefaultNotes
solc_version0.8.24Compiler version
evm_versionpragueEVM revision
optimizerfalseOptimizer on/off
optimizer_runs200Optimizer runs
via_irfalseIR pipeline
fuzz_runsfuzz.runsDefault Foundry fuzz runs (overrides fuzz.runs)
profiles—Overrides for the generated cambrian / cambrian_night profiles: fuzz_runs, invariant_runs, invariant_depth, fail_on_revert
remappings—Solc remappings ("prefix=path" strings)

The generated foundry.toml always contains two extra profiles: cambrian (fuzz 100, invariant runs 50, depth 50) for quick runs and cambrian_night (fuzz 10000, invariant runs 5000, depth 250) for long runs, selected with FOUNDRY_PROFILE=cambrian forge test.

fuzz / invariant

SectionKeyDefaultNotes
fuzzruns256Fuzz cases per test
fuzzseed00 = random; non-zero pins the RNG
fuzzmax_local_rejects1024Written as Foundry max_test_rejects
invariantruns256Invariant runs
invariantdepth50Calls per run
invariantfail_on_revertfalseFail the run on any reverting call
invariantseed00 = random; non-zero pins the RNG

fuzz.shrink is accepted for compatibility but has no effect; setting it produces an F7 warning.

In project mode these defaults are always written to foundry.toml. Single-file builds have no YAML and write only fuzz.runs = 256, leaving the rest to Foundry’s own defaults.

lean

KeyDefaultNotes
numericsoverflow-wrapoverflow-wrap (legacy alias bitvec), overflow-panic, or nat. Any other value is an error (F2)
proof_helperstruefalse drops generated proof helpers (simp lemmas, proof scaffolding); theorems are then emitted as statements with sorry
intrinsicsopaqueopaque or executable (computable, non-cryptographic hash models). Any other value is an error (F2)
lean_toolchainleanprover/lean4:v4.29.1Written to the generated lean-toolchain file

See also

Codegen Mapping

How Cambrian constructs lower on the public targets.

Solidity-EVM (--target evm)

CambrianSolidity
entity EContract E (flat file, pragma solidity ^0.8.24)
membersStorage variables (+ transforms inlined into route bodies)
routesExternal / public functions
private routes / call r(...)internal function _r, called directly (msg.sender stays the original caller)
view routesview functions
msg::sender / msg::valuemsg.sender / msg.value (payable when value is read)
~> dest typed sendExternal call
deploy E(...) / E.address(...)CREATE2 through the generated CambrianFactory (deployE / predictE)
init routeDeploy-time constructor stub + factory-only initialize(...)
event / emitSolidity event / emit
error / throw NameCustom errors / revert
test / property / invariantFoundry tests / fuzz / invariant handlers

On EVM every entity is deployed via CREATE2 by the generated CambrianFactory; there is no plain new E(...) path.

An identifier that collides with a Solidity keyword or built-in (now, bytes, type, mapping, …) gets a leading underscore in the generated Solidity (now → _now); the Cambrian source keeps the natural name.

Types

CambrianSolidity
u8 … u128, U256uint8 … uint128, uint256
i8 … i128int8 … int128
bool, addressbool, address
Address<E>address
String, bytesstring, bytes
bytes32, pubkeybytes32
Vec<T>T[]
HashMap<K, V>mapping(K => V)
Option<T>Generated struct Option_<T> (tag + value)
record Rstruct R
enum Eenum E; a payload-bearing enum becomes a tagged-union struct (E08)

Output layout

<out>/
  src/<E>.sol                  # one per entity
  src/_<name>_project.sol      # combined file (entities + CambrianFactory)
  test/<E>.t.sol               # tests and property test/fuzz instances
  test/Invariant_<E>_<name>.t.sol   # one per invariant
  foundry.toml
  setup.sh                     # installs forge-std
  .cambrian-manifest.json      # hashes of the generated files

In project mode <name> is the project name and each src/<E>.sol is a stub that imports the combined file. In single-file mode the combined file is src/_Harness_project.sol. A single-file build without tests, properties or invariants writes only src/<E>.sol (with all entities and the factory) and the manifest — no Foundry harness. A multi-entity invariant is written to test/Invariant_<name>.t.sol.

Build and test with bash setup.sh && forge test.

Lean-EVM (--target lean)

CambrianLean
entity statestructure State (plus ghost key lists for iterated maps when needed) and structure Identity
numeric typesBitVec n under the default lean.numerics: overflow-wrap (Nat under nat)
routesE.Routes.<route> (w : World) (inst : E.Identity) (ctx : MsgCtx) … returning World, or World × T with -> T
failing routes (where / throw / failing calls)Result wrapped in Cambrian.RouteResult α := Except ThrowCode α
sends / deploysWorld updates (call, occupancy, balances)
property / testTheorems in <E>Spec.lean
invariantAction / step / runTrace definitions + checks

Output layout

<out>/
  lakefile.toml
  lean-toolchain               # lean.lean_toolchain (default leanprover/lean4:v4.29.1)
  Cambrian.lean                # library root, imports everything
  Cambrian/{Core,Evm,Prelude,SimpAttrs}.lean   # vendored runtime
  Cambrian/Generated/<E>.lean         # State, Identity, member transforms
  Cambrian/Generated/<E>Routes.lean   # route functions over World
  Cambrian/Generated/World.lean       # shared world / storage
  Cambrian/Generated/<E>Spec.lean     # only when there are tests / properties / invariants
  Cambrian/Generated/Pure.lean        # only with top-level pure fns / records / enums
  Cambrian/Generated/Extern.lean      # only with extern entities (opaque route axioms)
  Cambrian/Generated/Dispatch.lean    # only with sends to address-typed destinations

Build with lake build (or pass --check-lean).

Grammar (Informal EBNF)

Informal sketch of the public surface. The authoritative grammar is cambrian-transpiler/src/cambrian.lalrpop. X? is optional, X* / X+ repeat, X,* is a comma-separated list, … elides detail.

Program        ::= TopLevel*

TopLevel       ::= ImportFile | UseDecl | PureFn | TypeAlias | RecordDecl
                 | EnumDecl | Entity | ExternEntity | EventDecl | ErrorDecl
                 | LibraryDecl | UsingDecl | TestDecl | PropertyDecl
                 | InvariantDecl

ImportFile     ::= "import" StringLit
UseDecl        ::= "use" Ident
PureFn         ::= "pure" "fn" Ident "(" Params ")" "->" Type "{" Block "}"
TypeAlias      ::= "type" Ident "=" Type                       // no ";"
Const          ::= "const" Ident ":" Type "=" Expr             // no ";"
RecordDecl     ::= "record" Ident "{" (Ident ":" Type),* "}"
EnumDecl       ::= "enum" Ident "{" (Ident ("(" Type,* ")")?),* "}"
EventDecl      ::= "event" Ident "(" ("indexed"? Ident ":" Type),* ")" ";"
ErrorDecl      ::= "error" Ident "(" Params ")" ";"
LibraryDecl    ::= "library" Ident "{" (PureFn | Const | TypeAlias)* "}"
UsingDecl      ::= "using" ("{" Ident,* "}" | Ident) "for" Type ";"

Entity         ::= "entity" Ident "{" EntityItem* "}"
EntityItem     ::= Const | Macro | RecordDecl | EnumDecl | TypeAlias
                 | EventDecl | ErrorDecl | RoutesBlock | Member
Macro          ::= "macro" Ident "(" Params ")" "->" Type "=" "{" Block "}"

ExternEntity   ::= ("@" "solidity_import" "(" StringLit ")")?
                   "extern" "entity" Ident "{" ExternRoute* "}"
ExternRoute    ::= ("view" | "accept")? "route" Ident "(" Params ")" ("->" Type)? ";"

Member         ::= Ident ":" Type ("=" Expr)? "{" Transform* "}"
                 | "identity" Ident ":" Type
Transform      ::= "in" Ident "(" Pattern,* ")" "=>" TransformBody
                 | "in" Ident "(" Pattern,* ")" "=>" (Ident ":" TransformBody)+   // per phase
TransformBody  ::= Expr | "{" Block "}"

RoutesBlock    ::= "routes" "{" Route* "}"
Route          ::= Attr* RouteHead "=>" "[" Body "]"
RouteHead      ::= Ident     "(" Params ")" From? ("->" Type)? Where?   // plain
                 | "accept"  Ident "(" Params ")" From? ("->" Type)? Where?
                 | "view"    Ident "(" Params ")" From? ("->" Type)? Where?
                 | "pure"    Ident "(" Params ")" ("->" Type)?
                 | "init"    Ident "(" Params ")" Where?
                 | "private" Ident "(" Params ")" Where?
Attr           ::= "#" "[" Ident ("(" (StringLit | IntLit) ")")? "]"      // e.g. #[factory_only]
From           ::= "from" FromAlt ("|" FromAlt)*
FromAlt        ::= Ident "(" Expr,* ")" ("with" "{" … "}")? ThrowSpec?  // entity form
                 | Ident ThrowSpec?                                     // member form
Where          ::= "where" Cond ("&&" Cond)*
                 | "where" "{" Cond,* "}"
Cond           ::= Expr ThrowSpec
ThrowSpec      ::= ":" "throw" (IntLit | Ident | Ident "(" Expr,* ")")

Body           ::= Action* | PhaseBlock*
PhaseBlock     ::= Ident Where? ":" "[" Action* "]"

Action         ::= "let" Pattern "=" LetValue ";"
                 | "var" Ident "=" Ident "(" Expr,* ")" "~>" Expr SendOpts? ";"
                 | "emit" Ident "(" Expr,* ")" ";"
                 | Ident "(" Expr,* ")" "~>" Expr SendOpts?             // typed send
                 | "~>" Expr SendOpts?                                  // value transfer
                 | "deploy" Ident ("(" Expr,* ")")? SendOpts?
                 | "return" "(" Expr,* ")"
                 | "throw" (IntLit | Ident | Ident "(" Expr,* ")")
                 | "call" Ident "(" Expr,* ")"
                 | Ident "::" Ident "(" Expr,* ")"                      // namespaced effect
                 | "if" Expr "=>" "[" Action* "]" ("else" "[" Action* "]")?
                 | "for" Pattern "in" Expr "=>" "[" Action* "]"
LetValue       ::= Expr | "if" … | "match" … | "{" Block "}"
SendOpts       ::= "with" "{" (Ident ":" Expr),* "}"

Block          ::= LetStmt* BlockTail
LetStmt        ::= "let" Pattern "=" (Expr | RecordLit | "if" … | "match" … | "{" Block "}") ";"
BlockTail      ::= Expr | RecordLit | "{" Block "}"
                 | "if" Expr "{" Block "}" ("else" "{" Block "}")?
                 | "match" Expr "{" (MatchPat "=>" (Expr | "{" Block "}")),* "}"
                 | "for" Pattern "in" Expr "{" Block "}"
RecordLit      ::= Ident "{" (Ident ":" Expr),* "}"
MatchPat       ::= Ident "::" Ident ("(" Pattern,* ")")? | "some" "(" Pattern ")"
                 | "none" | "_" | IntLit | "true" | "false" | Ident
Pattern        ::= Ident | "_" | "(" Pattern,* ")" | "some" "(" Pattern ")" | "none"

Expr           ::= Closure | Expr ".." Expr | BinaryExpr    // see Operator Precedence
Closure        ::= "|" Pattern,* "|" (Expr | RecordLit | "{" Block "}" | "if" …)
Primary        ::= IntLit | HexLit | BinLit | StringLit | BytesLit | "true" | "false"
                 | "array" "(" Expr,* ")" | "{" "}" | "(" Expr ("," Expr)+ ")"
                 | "^" Ident | "@" Ident "(" Expr,* ")"
                 | "msg::" Ident | "sys::" Ident | "trace::" Ident ("(" Ident ")")?
                 | "some" "(" Expr ")" | "none"
                 | Ident "::" Ident "::" Ident "(" Expr,* ")"   // std::math::min(…)
                 | Ident "::" Ident ("(" Expr,* ")")?            // Enum::Variant, evm::…
                 | "address_of" Ident "(" Expr,* ")"
                 | Ident "(" Expr,* ")" | Ident | "(" Expr ")"

TestDecl       ::= Attr* "test" StringLit "for" Ident ("skip" "from")?
                   ("with" "{" (Ident ":" Expr),* "}")? "{" TestStep* "}"
TestStep       ::= "call" (Ident ".")? Ident "(" Expr,* ")"
                 | Ident "{" (Ident ":" Expr),* "}"             // msg { sender: … }
                 | "let" Ident (":" Type)? "=" Expr
                 | "deploy" Ident "=" Ident "(" Expr,* ")" ("with" "{" … "}")?
                 | "expect" "state" "{" (FieldPath ":" Expr),* "}"
                 | "expect" "return" ("==")? Expr
                 | "expect" "return" ("." FieldPath | "[" … "]") ("==" | RelOp) Expr
                 | "expect" "throw" IntLit
                 | "expect" "emit" Ident "(" Expr,* ")"
                 | "expect" Expr                                // boolean predicate
                 | "assume" Expr | "bound" Ident "in" Expr (".." | "..=") Expr

PropertyDecl   ::= Attr* "property" StringLit ("(" Params ")")? "for" Ident
                   With? Ctx? "{" (TestStep | Instance)* "}"
Instance       ::= Attr* ("test" | "fuzz") StringLit? "{" Binding,* "}" With? Ctx?
Binding        ::= Ident ":" Expr | Ident "in" Expr (".." | "..=") Expr
With           ::= "with" "{" (Ident ":" (Expr | "*") | "*"),* "}"
Ctx            ::= "ctx" "{" (("msg::" | "sys::") Ident ":" (Expr | "*")),* "}"

InvariantDecl  ::= Attr* "invariant" StringLit Attr* "for" InvTarget Attr* "{" InvItem* "}"
InvTarget      ::= Ident | "{" (Ident ":" Ident),* "}"          // single / multi-entity
InvItem        ::= "init" Ident? "{" … "}" | "with" "{" … "}"      // Ident = instance
                 | "deploy" "{" Expr,* "}"
                 | "senders" "{" Expr,* "}" | Ctx
                 | "action" Ident ("." Ident)? "(" Params ")" "{" TestStep* "}"
                 | "check" Expr
                 | "track" "{" ("let" Ident "=" Expr ";"?)* "}"
                 | "derived" Ident "(" Params ")" "->" Type "{" TestStep* "return" Expr "}"
                 | "exclude" ("senders" | "selectors") "{" … "}"

Invariant action bodies additionally accept skip if Expr and advanceTime(Expr) (with #[with_time]).

Comments are // … only. Literals: decimal 1_000, hex 0xff, binary 0b1010, strings "…", bytes b"…". Strings and bytes decode the escapes \n, \r, \t, \0, \\ and \xHH; any other character, including non-ASCII text, is taken literally. A string can’t contain a " character (\" is not accepted).

See also Keywords and Operator Precedence.

Validation Rules

The transpiler reports diagnostics with short codes. Errors stop code generation (exit code 1); warnings do not. Run cambrian-transpiler <file.cam> --check (or --project p.yaml --check) to see them without writing output; --diagnostic-format json gives machine-readable output. The authoritative source is cambrian-transpiler/src/validate/. This page lists the codes that apply to the public targets (--target evm, --target lean).

Families

PrefixConcern
VEntity / route / member / phase / deploy / match
EEVM / Solidity compatibility
LLean target
Ttest / property / fuzz
Iinvariant
Fproject.yaml / imports
WWarnings / lints

Unless marked (warning), a code is an error. Codes tagged EVM fire for both --target evm and --target lean (both model the EVM); Solidity codes fire only for --target evm; Lean codes only for --target lean.

V — entities, routes, members

CodeSummary
V1Member transform in <route> names an unknown route
V2(warning) Route has no member transforms and an empty body
V3Unknown ^member, or a temporal cycle between members in one route
V4pure fn or library function reads an entity member, msg::*, sys::*, temporals, macros or impure evm::* (pass the value as a parameter)
V7Constant value is not a literal
V8Undefined identifier, function, macro or temporal reference
V9view route has member transforms, or (transitively) calls a route that has member transforms or state-changing actions
V10pure route has member transforms
V11pure route uses an impure construct (msg::*, sys::*, members, sends, deploys, call, …)
V12More than one init route, or an init route with a return type
V13Duplicate phase name
V14Unphased transform on a phased route, or phased transform on an unphased route
V15Transform references an unknown phase
V16 / V17identity member declares transforms / a default value
V20Empty string "" compared with an address
V22ns::name(...) without the matching use import — often a send misspelt as Entity::route(args) ~> dest (write route(args) ~> dest)
V23Named send or var capture to an untyped address (type the destination Address<E> or declare an extern entity), or to a route the target entity doesn’t declare
V24var x = r(...) ~> dest targets a route with no return type
V25var shadows a parameter or member, or is defined twice
V26var used in a member transform of the same or an earlier phase (or an unphased route)
V27Route-level where references a var defined in the body
V28Per-phase where references a var from the same or a later phase
V29Forbidden action inside an action-level for (var capture, return)
V30 / V31Duplicate extern entity (or clash with an entity) / duplicate extern route
V32deploy E(args) arity ≠ identity members + init-route params
V33EVM — from E(args) arity ≠ identity-member count of E
V34 / V35emit of an undeclared event / wrong arity or types
V36EVM — event with more than three indexed parameters
V38 / V39throw Name of an undeclared error / wrong arity or types
V40 / V41EVM — receive / fallback wrong shape (no params, no return, not view/pure; receive needs accept) / declared twice
V42return(value) in a route without -> T (bare return is fine)
V43^member outside a member transform body
V45Several deploy of the same entity in one route: warning when arguments differ, error when two are identical (address collision)
V47(warning) Unreachable match arm: after _ / a bare identifier, a duplicate arm, or a catch-all after arms that already cover every case (Lean drops that catch-all). Also an error for duplicate names inside a library
V48if without else where a value is required
V49Bare stdlib call (min, pow, sha256, …) without the std::math:: / std::str:: / std::crypto:: prefix
V50Transform assigns an incompatible type (string ↔ number needs std::str::parse_* / format; address vs Address<E>)
V51Unused let in a route or pure fn body
V52Record cycle without Option / Vec / HashMap indirection, or a type alias that never unfolds
V53Nested pattern in let some(<pat>) (use an identifier or _)
V54Route body mixes phase blocks and bare actions
V55using method name collides with a built-in method of the receiver type
V56using names an undeclared pure fn or library
V57using function’s first parameter type doesn’t match the for type
V58Invariant deploy { … } must contain exactly one address
V59deploy { … } on a multi-entity invariant
V60Several from alternatives with different : throw annotations
V62EVM — init/constructor route reads msg::sender (under factory deploy it is the factory); pass the recipient as a constructor parameter. Warning in single-file mode, error with --project
V63EVM — init/constructor route lacks #[factory_only]. Warning in single-file mode, error with --project
V64#[factory_only] on a route other than the init/constructor route
V65Arithmetic (+ - * / %, wrapping variants) on an Option<_> operand
V66Non-exhaustive match without a _ / binding arm: a missing enum variant, some / none, true / false, or integer-literal arms with no catch-all
V67Route or pure fn with -> T never returns a value (and never throws), or returns a value of a different category (numeric / String / bool)
V68Record literal R { … } sets a field twice, names an unknown field, or omits a field (use base { field: v } to update an existing value)
V69Signed and unsigned operands with no common signed type: arithmetic mixing a signed operand with u128 / U256, or a comparison with U256; cast one side with as
V70Member transform in r(a, b) binds a different number of parameters than route r declares; bind all (_ for unused ones) or none (in r())

E — EVM / Solidity

CodeSummary
E05(warning) accept on a regular route has no EVM meaning (payability is inferred); accept receive() is exempt
E07Solidity — error for a closure as a value or a bare range outside for; warning for an unsupported iterator chain inside for / .fold
E08(warning) Solidity — enum variant with payload lowers to a tagged-union struct
E09(warning) EVM — from lowers to a msg.sender comparison
E16EVM — namespace call with no EVM lowering (including an unknown evm:: intrinsic name or msg:: field), or encode<…>(…) outside tests
E17Solidity — unrecognised HashMap transform shape; use m.insert / m.update / m.remove (optionally inside if / block / let)
E18Solidity — tuple type outside a multi-return position; use a record
E19Solidity — generic type other than Vec / HashMap / Option
E20Solidity — type name that is not a primitive, record, enum or alias
E21Solidity — unsupported as target (allowed: u8–U256, i8–i128, bool, address, pubkey)
E22EVM — typed send to an entity that is neither in the program nor an extern entity
E23Solidity — mixed-type tuple destructure whose slot types can’t be inferred
E25EVM — std::* call outside the standard-library allowlist
E27Reported after codegen (not by --check): an expression had no EVM lowering and was replaced by 0 in the generated Solidity. The generated files stay on disk for inspection, but the exit code is 1
E28EVM — method with no Solidity or Lean lowering: Option .unwrap() / .unwrap_or() / .expect() / .is_some() / .is_none(), collection .get() / .set(). Use match / let some(x) = …, m[k], or a transform
E29Solidity — view route body contains a send, value transfer, deploy or emit (Lean accepts them)

L — Lean

CodeSummary
L1Generic type other than Vec / HashMap / Option
L2Closure as a value, or unsupported fold accumulator
L5expect throw N on a route that can never fail (vacuous theorem)
L6expect return … on a route without a return type
L7(warning) skip from is not honoured on Lean; from checks stay
L8Typed send whose destination doesn’t statically resolve to an in-program entity
L9var capture of a failing self-call from a route that can’t fail itself (add where / throw)
L10(warning) Invariant action route contains a send; Lean models the route as one atomic step
L11Fire-and-forget self-send to a failing route from a route that can’t fail
L13std::crypto::* (e.g. sha256) has no Lean lowering
L14Type name that is not a primitive, record, enum or alias
L15Unrecognised HashMap transform shape (Lean mirror of E17)
L16Namespaced call or msg:: / sys:: field with no Lean lowering (e.g. std::math::muldivmod, an unknown evm:: name)

T — tests, properties, fuzz

CodeSummary
T1for Entity names an unknown entity
T2 / T3call of an unknown route / wrong arity
T4with { … } / expect state { … } names a non-member field
T5expect before any call
T6Test with no call
T8(warning) Unknown context-block namespace (known: msg, sys)
T10Property parameter collides with a member, or a let shadows a parameter
T11bound in a property body, bad instance binding, or bound / assume in a plain test
T12assume expression is not boolean, or used in a plain test
T15skip if outside an invariant action body
T16advanceTime(...) outside an invariant action body (needs #[with_time])
T17Instance binding shape: test needs p: value, fuzz needs p in lo..hi
T18test instance doesn’t bind every property parameter
T19field: * names a non-member
T20Unknown name in ctx { … } (allowed: msg::{sender,value}, sys::{now,timestamp,chainid,block_number,balance})
T21Field both pinned and * in the same with { … }
T22msg:: / sys:: field in with { … } / init { … } instead of ctx { … }
T23Context parameter declared twice in one ctx { … }
T24Test deploy b = E(...) names an unknown entity
T25Test deploy binding name already in use
T26Test deploy constructor arity mismatch
T27Test deploy initial state names a non-member
T28call b.route(...) on a name not bound by an earlier deploy
T29call b.route(...): unknown route or wrong arity
T30Duplicate test let, let shadowing a parameter, or let x: T with a non-assignable initializer
T31Empty fuzz range for a parameter
T36expect emit in a property (only allowed in test)
T38Boolean expect that isn’t boolean-shaped, or mentions result after a route with no return type / when result is already a name in scope
T39Solidity — expect state on a member with no public Solidity getter (a Vec, a record holding a Vec / HashMap, or a map to Vec); assert through a view route and expect return

I — invariants

CodeSummary
I1action names an unknown route
I2action parameters don’t match the route (arity error; type mismatch is a warning)
I3check is not boolean
I4Forbidden statement in an action body (only bound / assume / skip if / advanceTime)
I5Invariant needs at least one action and one check
I6deploy { … } / senders { … } entry is not an address literal or constant
I8Duplicate instance name
I9Unknown instance entity, or action inst.route on an undeclared instance
I10init inst { … } names a non-member
I11Unqualified member in a multi-entity invariant (write inst.member)
I12derived body contains something other than let
I13Duplicate track name
I14exclude selectors { … } names an undeclared action
I15trace::* names an undeclared action or unknown accessor (length / count / lastWas)
I16trace::* outside an invariant assume / check
I17Solidity — check expression can’t be lowered for Foundry; move the logic into a view route
I18EVM — multi-instance invariant: several instances of an entity without identity members, duplicate identity values, or a cyclic deploy order

F — project configuration and imports

CodeSummary
F2Unknown lean.numerics value (expected overflow-wrap, overflow-panic, nat) or lean.intrinsics value (expected opaque, executable)
F4Imported file (import "…" or yaml imports:) declares entity / test / fuzz / invariant — list it under sources:
F5yaml imports: path or bare import "…" not found next to the importer or in any library_paths root
F6target: evm project that disables the CREATE2 factory deploy (on EVM, entities are always deployed via CREATE2 through the generated CambrianFactory)
F7(warning) Unknown project.yaml key (ignored), or fuzz.shrink (accepted, no effect)

Loader failures use named codes instead: PKG_IO (missing file), PKG_YAML (malformed YAML, missing target / sources, unknown target), PKG_MERGE (conflicting declarations across files), and PARSE_UNEXPECTED_TOKEN (syntax errors).

W — warnings

CodeSummary
W1Member has no transforms
W2pure fn is never called
W3Entity constant is never used
W4Duplicate use import
W5deploy of an entity not defined in the program
W7Fuzz parameter type uses a fallback strategy on this target
W8test instance leaves a field: * unpinned (falls back to the default); also a file imported twice
W9Entity-local record / enum / type shadows a program-scope one (they are distinct types)
W10EVM — route updates a balance/allowance-like member without an emit
W11In a test, call of the init route is followed by msg { sender: … }; set the sender first
W13Invariant bootstraps the constructor without an explicit deploy { … }
W14Invariant deploy address is also listed in senders
W15Invariant check pins member == const while an action can decrease that member (likely vacuous)

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 bash setup.sh && 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, 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). (A bare min(a, b) is accepted only when your program declares its own pure fn min, and then it calls that function.)

Is Cambrian open source?

Yes. The transpiler, reference contracts and component library are published in cambrian-lang under the GNU GPL v3.0 only, with prebuilt binaries on its Releases page.

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.