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:
| Target | CLI flag | Output |
|---|---|---|
| Solidity-EVM | --target evm (default) | Flat Solidity (^0.8.x) plus Foundry harness |
| Lean-EVM | --target lean | Lean 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, andinvariantblocks. - 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
- Prerequisites — Rust via rustup (only to build from source); optional Foundry and Lean 4 + Lake for EVM / Lean targets.
- Building from Source — download a
prebuilt binary, or clone
cambrian-langand runcargo build --release -p cambrian-transpiler; putcambrian-transpileron yourPATH.
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
| Tool | Version pinned or tested |
|---|---|
| Lean | leanprover/lean4:v4.29.1 (generated lean-toolchain; override with lean.lean_toolchain in project.yaml) |
| solc | 0.8.24, evm_version = "prague" (generated foundry.toml) |
| Foundry | 1.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
| Component | Needed for | Check |
|---|---|---|
| Rust / Cargo | Building from source | rustc --version |
Foundry (forge) | Running EVM tests | forge --version |
| elan / Lake | Building Lean output | lake --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]
| Flag | Meaning |
|---|---|
-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. |
--check | Parse, validate and check target compatibility; write nothing. |
--dump-ast | Pretty-print the parsed program and exit (no codegen). |
--check-lean | After --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). |
--version | Print the compiler build identity (--json for machine-readable output). |
Targets at a glance
--target | Output |
|---|---|
evm (default) | Flat Solidity under src/, plus Foundry harness when tests are present |
lean | Lean 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
- Your First Entity — walk through the real
contracts/counter.camsource. - Compile and Test — transpile to EVM (Foundry),
Lean (
lake build/--check-lean).
Prerequisites
Complete Installation first. You need at least:
cambrian-transpileron yourPATH(or invoke./target/release/cambrian-transpilerfromcambrian-lang)- Foundry (
forge) if you run the EVM test harness - Lean 4 +
lakeif 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)
]
}
| Route | Role |
|---|---|
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 previousm_countplusamount. - On
reset, the new value is0. - 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
- Language Guide for the full surface
- Targets for per-backend details
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 otherimports. Constants live inside anentityorlibrary(there is no top-levelconst). - Not importable:
entity,test,fuzz,invariant. Those stay on project entry sources (sources:inproject.yaml). - Duplicate names across files are the same error as two declarations
in one file.
imports:does not create a package namespace. - In
project.yamlmode, list entry points undersources:; transitive imports are picked up automatically. Optionallibrary_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:
| Item | Role |
|---|---|
import "…" | Pull in shared declarations (./ file-relative, or bare + library_paths) |
type / record / enum | Shared types |
pure fn | Stateless helpers |
library / using … for … | Reusable pure helpers and method sugar |
event / error | Log topics and custom errors |
extern entity | Foreign entity surface (other project or host) |
entity | Stateful unit with routes and members |
test / property / fuzz / invariant | Specs 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_]*):
| Element | Convention | Example |
|---|---|---|
| Entity names | PascalCase | Counter, Token |
| Route names | snake_case | deposit, get_balance |
| Member names | m_ + snake_case | m_balance, m_owner |
| Pure functions / macros | snake_case | max, is_owner |
| Types / records / enums | PascalCase | Amount, Status |
| Events / errors | PascalCase | Transfer, Unauthorized |
| Constants | SCREAMING_SNAKE_CASE | MIN_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:
| Category | Examples | Chapter |
|---|---|---|
| Primitives | bool, u64, U256, address | Primitive Types |
| Generics | Vec<T>, HashMap<K,V>, Option<T> | Generic Types |
| Records | record Pool { ... } | Records |
| Enums | enum Status { Active, Paused } | Enums |
| Type Aliases | type Amount = U256 | Type 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
| Type | Values |
|---|---|
bool | true, false |
Unsigned integers
| Type | Range | Typical use |
|---|---|---|
u8 | 0…255 | Flags, small counters |
u16 | 0…65 535 | Small IDs |
u32 | 0…2³²−1 | Medium IDs |
u64 | 0…2⁶⁴−1 | General counters |
u128 | 0…2¹²⁸−1 | Large counters |
usize | uint256 on EVM, Nat on Lean | Collection 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
| Type | EVM type | Use |
|---|---|---|
U256 | uint256 | Token amounts, balances, wide math |
m_balance: U256 {
in deposit(amount) => m_balance + amount
}
String and bytes
| Type | Description |
|---|---|
String | UTF-8 text |
bytes | Raw byte sequence (b"…" literals) |
bytes32 | Fixed 32-byte value (hashes) |
String↔number conversion is never implicit — use std::str::parse_* /
std::str::format (Standard Library).
Address
| Type | Description |
|---|---|
address | Party or entity address |
Prefer Address<Entity> when sending named messages — see
Generic Types.
pubkey
| Type | Notes |
|---|---|
pubkey | A 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
| Type | Common use |
|---|---|
bool | Flags, conditions |
u8…u128, i8…i128 | Counters, IDs, offsets |
U256 | Balances and amounts |
String / bytes | Text and payloads |
address | Callers 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)
}
| Method | Description |
|---|---|
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)
}
| Method | Description |
|---|---|
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.
| Feature | Address<Entity> | address |
|---|---|---|
| Named send | Checked against the target’s routes | Rejected (V23) |
| Plain value transfer | Allowed | Allowed |
| Compile-time route checks | Yes | No |
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>_Tagwith one variant per Cambrian variant; - a struct
<Name>with atagfield 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 Form | Type | Example |
|---|---|---|
| Decimal | Integer | 42, 1_000 |
| Hexadecimal | Integer | 0xFF, 0xDEAD |
| Binary | Integer | 0b101010 |
| Double-quoted string | String | "hello" |
| Byte string | bytes | b"raw" |
| Boolean | bool | true, false |
| Empty braces | Empty collection | {} |
array(...) | Vec<T> | array(1, 2, 3) |
some(value) | Option<T> | some(42) |
none | Option<T> | none |
| Record constructor | Record type | Foo { 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
| Category | Operators / Forms | Chapter |
|---|---|---|
| Arithmetic | + - * / % (checked); +% -% *% (wrapping) | Arithmetic and Logic |
| Comparison | ==, !=, <, <=, >, >= | Arithmetic and Logic |
| Logical | &&, ||, ! | Arithmetic and Logic |
| Bitwise | &, |, ^, <<, >> | Arithmetic and Logic |
| Control flow | if/else, match | Control Flow |
| Iteration | for x in iter { body } | See Iteration (for) below |
| Blocks | { let a = ...; expr } | Blocks and Let Bindings |
| Field access | record.field, map[key] | Method Calls and Field Access |
| Method calls | list.len(), map.exists(key) | Method Calls and Field Access |
| Type casts | expr as Type | Method 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
letbindings, 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:
| Form | Meaning |
|---|---|
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
| Operator | Operation | Example |
|---|---|---|
+ | Addition | a + b |
- | Subtraction | a - b |
* | Multiplication | a * b |
/ | Division | a / b |
% | Remainder | a % 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
| Scenario | Use |
|---|---|
| Token balances, financial math | Checked (+, -, *) |
| Hash computations, bit manipulation | Wrapping (+%, -%) |
| Counter that should wrap at max | Wrapping (+%) |
| Any case where overflow is a bug | Checked (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
| Operator | Meaning | Example |
|---|---|---|
== | Equal | a == b |
!= | Not equal | a != b |
< | Less than | a < b |
<= | Less than or equal | a <= b |
> | Greater than | a > b |
>= | Greater than or equal | a >= 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
| Operator | Meaning | Example |
|---|---|---|
&& | Logical AND | a && b |
|| | Logical OR | a || 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
| Operator | Operation | Example |
|---|---|---|
& | Bitwise AND | a & b |
| | Bitwise OR | a | b |
^ | Bitwise XOR | a ^ b |
<< | Left shift | a << n |
>> | Right shift | a >> 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:
| Precedence | Operators |
|---|---|
| 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:
| Element | Purpose | Required |
|---|---|---|
| Type aliases | Named synonyms for types | No |
| Records | Product types (structs) | No |
| Enums | Sum types (tagged unions) | No |
| Constants | Compile-time fixed values | No |
| Macros | State-aware helper expressions | No |
routes { } | Named entry points | No |
| Members | State variables with transforms | No |
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
| Kind | Keyword | State | Side effects | Use |
|---|---|---|---|---|
| Regular | (none) | Read + write | Yes | Mutations |
| View | view | Read only | No | Queries |
| Pure | pure | None | No | Stateless computation |
| Init / constructor | init (or constructor-style) | Write | Yes | One-time setup |
| Private | private | Read + write | Yes | In-entity only; use with call |
| Receive | accept receive() | Read + write | Yes | Plain ETH receiver |
| Fallback | fallback() | Read + write | Yes | Unknown 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]
| Part | Required | Description |
|---|---|---|
| Modifier | No | view, pure, init, private, or accept on receive |
| Name | Yes | Route identifier (receive / fallback are reserved on EVM) |
| Parameters | Yes | Typed list (empty for receive / fallback) |
| From clause | No | Sender verification — From Clause |
| Return type | No | -> T when returning a value (V42 if return(expr) lacks it) |
| Where clause | No | Preconditions — Where Clause |
| Actions | Yes | => [ … ] (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:
| Allowed | Forbidden |
|---|---|
| Read member values | Modify members (no transforms) |
| Call pure functions | Send messages or value (~>) |
Use if/else, match | Deploy entities |
| Compute intermediate values | Emit 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:
| Allowed | Forbidden |
|---|---|
| Parameters | Read member values |
| Arithmetic, logic | Write member values |
if/else, match | Send messages (~>) |
let bindings | Deploy entities |
return(value) | Platform / host effects |
| Call pure functions | Access 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:
| Feature | Pure Route | Pure Function |
|---|---|---|
| Declaration | Inside routes { } with pure | Top-level with pure fn |
| Callable externally | Yes (via message) | No |
| Callable internally | As a route | Yes, from any expression |
| Syntax | pure 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
whereclauses 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
| Scenario | Prefer |
|---|---|
| Public caller actions | No from (or where on msg::sender) |
| Callbacks from a known Cambrian entity | from Entity(…) |
| Admin / oracle entities with predicted addresses | from Entity(…) on EVM |
| Plain address stored in a member | from 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
vieworpure.
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
| Action | Syntax | Chapter |
|---|---|---|
| Send message | route(args) ~> dest | Send Messages |
| Plain transfer | ~> dest with { value: amount } | Send Messages |
| Deploy | deploy Entity(args) | Deploy |
| Emit event | emit Event(args); | Emit Events |
| Conditional | if cond => [actions] else [actions] | Conditional Actions |
| Return | return(value) | Return Values |
| Call | call route(args) | Call |
| Let binding | let 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:
fromchecks, thenwherechecks — any failure reverts the whole route.- Every member transform is computed (from the values the members had before the route) and the results are stored.
- Actions execute in declaration order:
letbindings 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 (
~>, includingvarcaptures),call, anddeploy— read the value from before the route’s transforms. - Everything else —
letbindings,emitarguments,ifconditions, andreturn— 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:
- Emits a project-wide
CambrianFactory. - Lowers
deploy Entity(args)to a factory call that performs CREATE2 with the entity’s identity arguments as constructor args. - 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
}
}
| Target | Lowering |
|---|---|
| EVM | An internal Solidity function (_apply_total), called directly |
| Lean | Internal 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 caller | Forbidden |
|---|---|
Regular, init, phased routes | pure routes calling private (V11) |
call after sends, deploys, let | call 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
}
| Part | Description |
|---|---|
| Name | m_ prefix followed by snake_case |
| Type | Any Cambrian type |
| Transforms | One 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/whereclauses and transform bodies see the value from before the route (use^m_xinside 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, anddeploystill read the value from before the route;let,emit,ifconditions, andreturnread 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:
| Type | Example Use |
|---|---|
u64, U256 | Counters, balances, amounts |
bool | Flags, status indicators |
address | Owner, treasury, linked entities |
String | Names, labels |
HashMap<K, V> | Mappings (balances, approvals) |
Vec<T> | Lists (participants, history) |
Option<T> | Optional configuration |
| Records | Grouped state (deal details, config) |
| Enums | State 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:
| Reference | Resolves To |
|---|---|
m_count | Value before the current route fires |
^m_count | Value 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
^m_xis allowed only inside member transform bodies. Ifm_xhas no transform for the current route,^m_xis simplym_x.- Circular temporal dependencies (where
^m_adepends on^m_bwhich depends on^m_a, or a member reads its own^value) are rejected by the compiler. - Temporal references are resolved statically at compile time – there is no runtime overhead.
^applies to a whole member or to an expression over it (^m_total,^m_items.len(),^m_balances[k]for the new entry atk).
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:
| Rule | Code | Meaning |
|---|---|---|
| Event must be declared | V34 | emit of an unknown name is an error |
| Arity and types must match | V35 | Argument count / types must match the declaration (narrow integers may auto-cast to declared widths) |
| Pure routes cannot emit | V11 | emit is a side effect |
| View routes cannot emit | E29 | Rejected on EVM (Solidity forbids emit in a view function) |
See also Emit Events for the action form in a route body.
EVM lowering
| Cambrian | Solidity |
|---|---|
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
| Form | Where |
|---|---|
: 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
| Rule | Code |
|---|---|
| Error must be declared at program or entity scope | V38 |
| Argument arity / types must match | V39 |
EVM lowering
| Cambrian | Solidity |
|---|---|
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:
| Modifier | Meaning on EVM |
|---|---|
| (none) | Ordinary mutating call |
view route | Read-only (staticcall-safe) |
accept route | Payable 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
| Rule | Code | Meaning |
|---|---|---|
| Unique name | V30 | Duplicate extern entity, or collision with a real entity |
| Unique route names | V31 | Duplicate route signature inside one extern block |
| Known send target | E22 | Typed 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:
| Allowed | Forbidden |
|---|---|
| Parameters | Read member values |
| Arithmetic, logic | Write member values |
if/else, match | Send messages |
let bindings | Access msg:: or sys:: |
| Call other pure functions | Side effects (~>, deploy, emit, evm::) |
| Type casts | Call 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
| Feature | Pure Function | Macro |
|---|---|---|
| Declaration | pure fn name(...) -> T { ... } | macro name(...) -> T = { ... } |
| Location | Top-level (outside entities) | Inside entity |
| State access | None | Can read members |
| Scope | All entities in the file | Enclosing entity only |
| Call syntax | name(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
| Feature | Pure Function | Pure Route |
|---|---|---|
| Declaration | pure fn name(...) -> T { ... } | pure name(...) -> T => [return(...)] |
| Callable externally | No | Yes (via message) |
| Internal calls | Yes, from any expression | As a route |
| Use case | Internal computation | On-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
fortype. - When the
fortype isVec,HashMap, orOption, 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.camfiles. 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:
| Context | Example |
|---|---|
| Where clauses | where @is_owner() : throw 100 |
| Route actions | let fee = @fee_for(amount); |
| Member transforms | in deposit(amount) => m_balance + @fee_for(amount) |
| Conditional actions | if @is_active() => [...] |
Macros vs. Pure Functions
| Feature | Macro | Pure Function |
|---|---|---|
| State access | Yes (reads members) | No |
| Context access | Yes (msg::, sys::) | No |
| Scope | Enclosing entity only | All entities in the file |
| Call syntax | @name(args) | name(args) |
| Declaration | Inside entity | Top-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).
| Function | Role |
|---|---|
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:
| Spec | Meaning |
|---|---|
{} | Default display |
{:w} | Minimum width w |
{:0w} | Zero-pad to width w (for example {:06} → 000042) |
Arguments may be integers or strings.
std::crypto
| Function | Signature | Semantics |
|---|---|---|
std::crypto::sha256 | (bytes or String) -> bytes | SHA-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 withoutstd::str::parse_*→ V50- numeric expression assigned / returned as
Stringwithoutstd::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 —
stdlib/math.cam, ERC-20 / ERC-7943 tokens, ERC-4626 vaults - EVM Intrinsics
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
| Path | Kind | Role |
|---|---|---|
stdlib/math.cam | helpers | Integer sqrt and a wide mul_div |
stdlib/token/erc20-core.cam | helpers | Balance / allowance / EIP-712 helpers |
stdlib/token/ERC20.cam | entity | Singleton ERC-20 + EIP-2612 permit |
stdlib/token/ERC20Multi.cam | entity | Same token with an identity (several instances) |
stdlib/token/erc7943-core.cam | helpers | Shared ERC-7943 permission helpers |
stdlib/token/ERC7943Min.cam | entity | Permissioned token: who may move value |
stdlib/token/ERC7943.cam | entity | Permissioned token plus exact accounting |
stdlib/vault/erc4626-core.cam | helpers | Asset interface and share/asset conversion |
stdlib/vault/ERC4626Min.cam | entity | ERC-4626 route set, textbook ratios |
stdlib/vault/ERC4626.cam | entity | ERC-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:
| Function | What 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 plusidentity 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 onERC20.
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-controlledforcedTransfer, 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 moveamount). 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— textbookassets/sharesratios, 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)
| Field | Type | Description | Solidity lowering | Lean |
|---|---|---|---|---|
msg::sender | address | Caller of this call | msg.sender | yes |
msg::value | U256 | Native currency attached to the call | msg.value | yes |
These are the fields you should rely on for access control and payable logic.
An unknown msg:: field is rejected.
Optional time alias
| Field | Notes |
|---|---|
msg::timestamp | Lowers 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
| Context | Example |
|---|---|
| Where clauses | where msg::sender == m_owner : throw Unauthorized() |
| Route actions | ~> msg::sender with { value: amount } |
| Member transforms | in deposit() => msg::value |
| Macros | macro 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
| Field | Description | Typical EVM lowering | Lean |
|---|---|---|---|
sys::now / sys::timestamp | Block / host timestamp | block.timestamp | yes |
sys::block_number | Block number | block.number | yes |
sys::chainid | Chain id | block.chainid | yes |
sys::address | This entity’s address | address(this) | yes |
sys::balance | This entity’s native balance | address(this).balance | yes |
sys::coinbase | Block coinbase | block.coinbase | — |
sys::basefee | Block base fee | block.basefee | — |
sys::blobbasefee | Blob base fee (EIP-4844) | block.blobbasefee | — |
sys::prevrandao | Block randomness | block.prevrandao | — |
sys::gas_left | Remaining gas | gasleft() | — |
sys::origin | tx.origin (use sparingly) | tx.origin | — |
sys::gasprice | Transaction gas price | tx.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:
| Intrinsic | Returns | Lowers to |
|---|---|---|
evm::ecrecover(hash, v, r, s) | address | ecrecover(...) (address(0) on bad signature) |
evm::keccak256(x) | U256 | uint256(keccak256(abi.encode(x))) |
evm::keccak256Packed(a, b, ...) | U256 | uint256(keccak256(abi.encodePacked(a, b, ...))) |
evm::sha256(a, ...) | U256 | uint256(sha256(abi.encodePacked(a, ...))) |
evm::ripemd160(a, ...) | U256 | ripemd160(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):
| Intrinsic | Returns | Lowers to |
|---|---|---|
evm::balance(addr) | U256 | addr.balance |
evm::blockhash(n) | U256 | uint256(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
| Key | Role |
|---|---|
name | Project name (informational / packaging) |
target | Backend: typically evm or lean |
sources | Entry-point .cam files (relative to the YAML) |
library_paths | Optional extra directories to search for shared .cam files (${VAR} expanded; absolute or relative to the YAML). None are built in. |
imports | Shared 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_dir | Where generated code is written (default build/) |
foundry | Foundry / solc options (optimizer, via_ir, remappings, …) |
fuzz | Shared fuzz defaults (runs, seed, …) |
invariant | Shared invariant defaults (runs, depth, fail_on_revert, …) |
lean | Lean-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 eachlibrary_pathsdirectory.import "./lib.cam"— always relative to the importing file. A missing file is an ordinary “file not found”;library_pathsis not searched.import "token/erc20-core.cam"(no.//../) — same directory as the importer first, then eachlibrary_pathsentry.- An imported file must not declare
entity,test,fuzz, orinvariant— put those onsources:. 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:
varcapture — a synchronous return from an external call that later transforms or guards depend on.- Interleaving — an effect must run between state updates (or between other effects) rather than after all of them.
- Snapshot before overwrite — a
let,ifcondition,emit, orreturnneeds 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
| Need | Pattern |
|---|---|
| Read a remote view, then gate local writes | fetch: with var x = … ~> dest, then act where … |
| Bind (log, branch on) a value the route resets | snap: with let x = m_…;, then pay: with the transform and the actions |
| Optimistic transfer, then callback, then check | Three phases (see Uniswap V2 swap) |
| Effect between two groups of transforms | Explicit 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
- Phases and their order live exclusively in the route body (
tag: […]). - In a phased route, every participating transform must carry a phase tag.
- In an unphased route, transforms must not carry phase tags.
- Untagged actions are illegal inside a phased body.
| Situation | Error |
|---|---|
| Transform tag missing from the route | unknown phase |
| Phased route, transform without a tag | transform must specify a phase |
| Unphased route, transform with a tag | route has no phases |
| Untagged action in a phased body | untagged 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:
| Clause | May reference vars from… | Error if violated |
|---|---|---|
Route-level where | Never (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
whereon captured post-call state (as Uniswap V2swap’scheck: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.
| Reference | Meaning in phase Pᵢ |
|---|---|
m_a | Value immediately before Pᵢ’s transforms (already includes earlier phases) |
^m_a | Value 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:
| Phase | Expression | m_x seen | Result |
|---|---|---|---|
first | m_x + 1 | 0 | m_x becomes 1 |
second | m_x + 10 | 1 (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
| Context | m_x | ^m_x |
|---|---|---|
| Unphased transform | Pre-route | Post-route |
| Phase Pᵢ transform | Pre-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
| Property | Ordinary member | Identity member |
|---|---|---|
| Mutable after deploy | Yes (transforms) | No |
| Default value | Allowed | Forbidden |
| Transforms | Allowed | Forbidden |
| Address arity | Does not count | Counts 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:
- CREATE2-deploy with the factory address and identity args as constructor
arguments (address matches
Entity.address(…)), forwarding any attached value. - 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
| Expression | Role |
|---|---|
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).
| u8 | u16 | u32 | u64 | u128 | U256 | i8 | i16 | i32 | i64 | i128 | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| u8 | u8 | u16 | u32 | u64 | u128 | U256 | i16 | i16 | i32 | i64 | i128 |
| u16 | u16 | u32 | u64 | u128 | U256 | i32 | i32 | i32 | i64 | i128 | |
| u32 | u32 | u64 | u128 | U256 | i64 | i64 | i64 | i64 | i128 | ||
| u64 | u64 | u128 | U256 | i128 | i128 | i128 | i128 | i128 | |||
| u128 | u128 | U256 | ERR | ERR | ERR | ERR | ERR | ||||
| U256 | U256 | ERR | ERR | ERR | ERR | ERR | |||||
| i8 | i8 | i16 | i32 | i64 | i128 | ||||||
| i16 | i16 | i32 | i64 | i128 | |||||||
| i32 | i32 | i64 | i128 | ||||||||
| i64 | i64 | i128 | |||||||||
| i128 | i128 |
Practical guidance
- Prefer same-sign arithmetic so widening stays obvious.
- Mixing
u64with signed values yieldsi128— larger than most balance fields. On EVM, keep mixed-sign expressions out of route bodies (see Rule 2). - Do not mix
u128/U256with 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
| Op | Failure |
|---|---|
+ - * | Overflow / underflow of the result type after widening — EVM Panic(0x11) |
/ % | Divisor is zero — EVM Panic(0x12) |
narrowing as uN / as iN | Value 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.numerics | Carrier | + - * in Lean | +% -% *% |
|---|---|---|---|
(absent), overflow-wrap, or legacy bitvec | BitVec n | wrapping infix (not EVM panics) | wrapping infix |
overflow-panic | BitVec n | Cambrian.checkedAdd / checkedSub / checkedMul (signed: checkedS*) → fail ThrowCode 0x11 | wrapping infix |
nat | Nat / Int | total / saturating proof arithmetic — overflow ignored | degrades 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
| Type | Checked + - * | Wrapping +% -% *% |
|---|---|---|
u8–u128 | yes | yes |
i8–i128 | yes | yes |
U256 | yes | yes |
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
| Prefer | When |
|---|---|
+ - * | Default on EVM — balances, tallies, supply |
+% -% *% | Spec requires modular math (TWAP accumulators, hash-like folds) |
lean.numerics: overflow-panic | Lean 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
| Domain | Meaning | Languages | CLI targets |
|---|---|---|---|
| EVM | Synchronous call / CREATE2 / ETH-balance model | Solidity, Lean | evm, 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 name | CLI / project.yaml | Domain | Language | Typical output |
|---|---|---|---|---|
| Solidity@ETH / Solidity-EVM | --target evm (default) | EVM | Solidity | Flat src/*.sol (pragma solidity ^0.8.24), Foundry tests, foundry.toml |
| Lean-EVM | --target lean | EVM | Lean 4 | Lake 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
| Capability | evm | lean |
|---|---|---|
| Models EVM (CREATE2, payable, balances, …) | yes | yes |
evm::* intrinsics, msg:: / sys:: fields | all | subset (see System Context, EVM Intrinsics) |
Desugars property → test / fuzz | yes | no (keeps theorems) |
| Multi-entity invariants | yes | yes |
| Primary test oracle | forge test | lake 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 tosorry).
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
| Chapter | Contents |
|---|---|
| Solidity-EVM | Flat Solidity, Foundry, CambrianFactory, payable, events/errors |
| Lean-EVM | Lake 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:
| Profile | Role |
|---|---|
default | Full local runs (fuzz / invariant knobs from project.yaml) |
cambrian | Cheap CI (fuzz.runs ≈ 100, invariant.runs ≈ 50) |
cambrian_night | Overnight 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(andICambrianFactory) with onedeploy<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(…), whichdeploy<Entity>calls. Mark the init route#[factory_only]and pass recipients (owner, mint beneficiary) as explicit parameters — under the factory,msg::senderin the init route is the factory. Entity.address(args)andaddressOf(Entity.state(args))lower to the same CREATE2 prediction used in sends andfromclauses.
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::valuebecomespayable. receiveis alwayspayable;fallbackispayableonly when it readsmsg::value.- Constructors (and the factory’s
deploy<Entity>) arepayableby default sodeploy … with { value: … }works. Setevm.allow_constructor_payable: falseinproject.yamlto make them non-payable (the factory then deploys withvalue: 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
indexedevent 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
Statelike EVMinitialize. - Multi-entity invariants lower to
Action+step+runTracein 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
| Placement | Policy |
|---|---|
Main theorems / lemmas in *Spec.lean | Automatic simp / omega attempt; a fallback sorry is expected |
defs, terms, non-proof contexts | No sorry — fail closed |
| Small helper lemmas | May 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):
| Codes | Theme |
|---|---|
| L1–L2 | Collection / iterator lowering limits (most Vec / HashMap / folds are supported) |
| L5–L6 | Vacuous specs: expect throw on a total route; expect return without -> T |
| L7 | skip from not yet honoured on Lean (warning) |
| L8 | Typed send dest must resolve to an in-program Entity.address / addressOf |
| L9 / L11 | Capturing or fire-and-forget self-call to a failing route without a fail surface |
| L10 | Invariant action contains a send (atomic step elides interleavings) — warning |
| L13–L14 | Missing Lean lowering for std::crypto::* / unresolved type identifiers |
| L15 | HashMap member-transform body shape not recognised (use insert / update / remove) |
| L16 | Namespaced 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
| Construct | Role | Typical lowering |
|---|---|---|
test | Concrete, deterministic scenario | Foundry test_*, Lean theorem with fixed values |
property | Parameterised statement + nested test / fuzz instances | Lean: one ∀ theorem. EVM: concrete tests and fuzz cases |
invariant | Stateful random call sequences + checks | Foundry 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
testanswers “does this exact scenario pass?” - A
propertyanswers “does this hold for a family of inputs?” — Lean keeps the family; fuzz backends sample it. - An
invariantanswers “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)
| Backend | How you run it | Driven by |
|---|---|---|
| Foundry | forge test after --target evm | test / property instances / invariant |
| Lean Spec | lake build / --check-lean | Raw 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 invariantsmsg::/sys::live inctx { … }(T22). Plain tests set context per step withmsg { … }/sys { … }. assumevsbound: logical preconditions vs sampling ranges — onlyassumereaches Lean.- Desugar:
--target evmlowerspropertybefore codegen; Lean keeps properties intact for clean theorems. - Expects stack: after each
call, you may assertstate,throw,return, and (where supported) effects;expect emitgoes before the call it observes. Lean conjoins everyexpecton that call. - Shared fixtures: Domain EVM cores (
evm,lean) should reuse the same.cammatrices 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
| Chapter | Contents |
|---|---|
| Unit tests | test syntax, lenses, multi-step, skip from, with / msg / sys |
| Properties | Nested instances, assume/bound, forall * |
| Invariants | Actions/checks, track/derived, trace::*, multi-entity, attributes |
| Backends | Foundry 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
| Element | Meaning |
|---|---|
test "name" for Entity | Declaration; entity must exist |
skip from | Disable all from-clause sender checks in this test |
with { field: value, … } | Initial entity state (members only) |
let name = expr / let name: T = expr | Binding 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 N | Assert 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 E | Assert return payload equals E (route must declare -> T) |
expect return == E | Same 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). Amsg::/sys::key inside a test’swithis 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
- Typed parameters attach to the property name (before
for). - The body holds logical steps only:
msg/sysorctx,let,assume,call,expect*. - Concrete
testand randomisedfuzzinstances 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 Eandexpect return == Estay 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, butm_balanceinside 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 calledresultand the predicate mentions it, validation reports T38. expect state { … }stays a list of equalities.
Notes:
- Write
for Counter, neverfor Counter(amount: u64). - Sampling
bounds belong in fuzz instances, not the property body (T11). - Instance forms:
test { p: value, … }(every param fixed) andfuzz { p in lo..hi, … }(ranges; omitted params use the full type range). - Property-level
withis the default initial state; instances may override with their ownwith. - 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
| Construct | Role | Where | Reaches Lean? |
|---|---|---|---|
assume <bool> | Logical precondition | property body | yes (→ hypothesis) |
bound x in lo..hi | Sampling range | fuzz instance | no |
bound x in lo..=hi | Inclusive range | fuzz instance | no |
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 with | Meaning |
|---|---|
field: v | pin 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 form | Meaning |
|---|---|
msg::x: v / sys::x: v | pin |
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:
| Target | Forall state / ctx becomes… |
|---|---|
| Lean | ∀-bound seeding world / MsgCtx |
| fuzz (EVM) | Extra sampled input |
concrete test | State forall falls back to default (W8); ctx forall dropped |
How Foundry runs properties
On --target evm, each nested instance becomes a runnable harness:
fuzzinstance → fuzz case withbound p in lo..hi, then the logical body.testinstance → concrete test withlet p = value;assumes are dropped (values are presumed valid).
Lean keeps the property as a single theorem instead (see Lean-EVM).
How preconditions lower:
| Backend | assume | bound |
|---|---|---|
| Foundry | vm.assume | StdUtils.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
}
| Construct | Meaning |
|---|---|
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
| Keyword | When 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..hi | Reject 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):
| Accessor | Meaning |
|---|---|
trace::length | Actions 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 / block | Effect |
|---|---|
#[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(…) -> T | Pure view helper on the handler |
exclude senders / exclude selectors | Foundry 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.solplusfoundry.toml(andsetup.sh). - Unit
test→ Soliditytest_*functions withvm.prank/ storage setup. - Desugared
propertyfuzz →function testFuzz_*withvm.assume/bound. invariant→ one filetest/Invariant_<Entity>_<name>.t.solwith a<Entity>Handler_<name>handler and anInvariant_<Entity>_<name>_Testsuite (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 inCambrian/Generated/<E>Spec.lean.- Each
propertybecomes a theorem; fuzz sampling ranges never appear in the statement. - Main theorems get an automatic
simp/omegaattempt with asorryfallback; modeldefs must not containsorry. lake buildtype-checks the project; discharging specs is a separate effort.
Cross-target support matrix
| Feature | Foundry | Lean |
|---|---|---|
Concrete test | yes | theorem |
property + nested test | yes | theorem |
property + nested fuzz | yes | theorem (no bounds) |
Single-entity invariant | yes | runTrace |
Multi-entity invariant | yes | yes |
trace::* | yes | TraceAcc |
#[with_time] / track / derived | yes (rich) | modelled where applicable |
skip from | yes | warn L7 |
Fuzz parameter types
| Type | Foundry |
|---|---|
u8…u128, i8…i128, U256, bool, address | yes |
bytes, bytes32 | yes |
String | fuzzed, but warns W7 |
Other types (records, collections, …) warn (W7) instead of hard-failing; the harness is still emitted but may degrade.
Practical workflow
- Write fixtures once under
contracts/or your project sources. - Gate on Foundry (
forge test) for EVM behaviour. - Add Lean emit (+
lake buildwhen 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/resetmutate state only — empty action lists.getCountreturns the current value; it has no member transform, som_countis 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):
| File | Role |
|---|---|
contracts/counter.test.cam | Unit tests: increment, reset, expect return, multi-step |
contracts/counter.fuzz.cam | Properties + fuzz { amount in … } ranges; forall m_count: * |
contracts/counter.invariant.cam | Invariants 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 fixture | What this chapter borrows |
|---|---|
contracts/token.cam | Core transfer / approve / balance surface |
contracts/erc20_events_evm.cam | event + emit |
contracts/erc20_errors_evm.cam | Named error + throw Error(…) |
The canonical minimal token in the workspace still uses numeric throws and
no events — open contracts/token.cam when you need that baseline.
Helpers and surface
pure fn balance_of(balances: HashMap<address, U256>, owner: address) -> U256 {
if balances.exists(owner) { balances[owner] } else { 0 }
}
entity Token {
const ZERO: address = 0x0000000000000000000000000000000000000000
event Transfer(indexed src: address, indexed dst: address, value: U256);
event Approval(indexed owner: address, indexed spender: address, value: U256);
error InsufficientBalance(have: U256, need: U256);
error ZeroAmount();
routes {
#[factory_only]
constructor(holder: address, initial_supply: U256) => [
emit Transfer(ZERO, holder, initial_supply);
]
transfer(to: address, amount: U256)
where amount > 0 : throw ZeroAmount() &&
balance_of(m_balances, msg::sender) >= amount
: throw InsufficientBalance(balance_of(m_balances, msg::sender), amount)
=> [
emit Transfer(msg::sender, to, amount);
]
approve(spender: address, amount: U256) => [
emit Approval(msg::sender, spender, amount);
]
view balanceOf(owner: address) -> U256 => [
return(balance_of(m_balances, owner))
]
view totalSupply() -> U256 => [
return(m_total_supply)
]
}
m_total_supply: U256 {
in constructor(_, initial_supply) => initial_supply
}
// m_balances and m_allowances below…
}
where / throw Name(…) pair with declared errors; emit pairs with
declared events. Up to three indexed fields per event on EVM (V36).
The balance check lives in where, which runs before any member
transform; a let or emit in the route body would already see the updated
map.
The initial holder is an explicit constructor parameter and the constructor
is #[factory_only]: on EVM the token is deployed through the generated
CambrianFactory, so msg::sender inside the constructor would be the
factory, not the deployer.
HashMap balances
m_balances: HashMap<address, U256> {
in constructor(holder, initial_supply) => {
{}.insert(holder, initial_supply)
}
in transfer(to, amount) => {
let sender = msg::sender;
let sender_bal = balance_of(m_balances, sender);
let to_bal = balance_of(m_balances, to);
if to == sender { m_balances } else {
m_balances
.update(sender, sender_bal - amount)
.update(to, to_bal + amount)
}
}
}
Maps are persistent values: .insert / .update return a new map; the
transform’s final expression becomes the stored member. Empty map
literal: {}. Both balances are read before either is written, so a
self-transfer (to == sender) must return the map unchanged — otherwise the
credit would overwrite the debit and mint amount out of nothing.
Nested allowances
m_allowances: HashMap<address, HashMap<address, U256>> {
in approve(spender, amount) => {
let sender = msg::sender;
let inner = if m_allowances.exists(sender) {
m_allowances[sender]
} else {
{}
};
m_allowances.update(sender, inner.update(spender, amount))
}
}
transferFrom decrements the inner map the same way (see
contracts/token.cam for the full mint / burn / allowance path).
Patterns worth copying
| Pattern | Where |
|---|---|
pure fn for map lookups used in where and transforms | balance_of |
Named error + throw Error(…) | erc20_errors_evm.cam |
event + emit | erc20_events_evm.cam |
Unphased CEI: update maps, then emit | transfer body above |
For a production-shaped ERC-20 with EIP-2612 permit and optional
multi-instance identity, use the shipped
Contract Standard Library
(stdlib/token/ERC20.cam / ERC20Multi.cam). The Uniswap V2 example
keeps its own pair token in examples/uniswap-v2/ERC20.cam.
Tests in the workspace
| File | Role |
|---|---|
contracts/token.cam + contracts/token.test.cam | Baseline entity (numeric throws, no events) and its unit tests |
contracts/erc20_events_evm.cam | Event / emit patterns |
contracts/erc20_errors_evm.cam | Named errors |
contracts/token.cam follows the factory rules like the merged Token
above: its #[factory_only] constructor takes the owner as an explicit
first parameter, constructor(owner, name, symbol, decimals, initial_supply).
List token.cam and token.test.cam under sources: in a project.yaml
and build it the same way as Counter: with target: evm
the 12 unit tests pass under forge test, with target: lean the theorems
build with lake build.
The two erc20_*_evm.cam fixtures predate factory deployment: their
constructors read msg::sender and carry no #[factory_only], so EVM
project mode rejects them (single-file mode only warns). Read them for the
event and error patterns.
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 type | magic numbers | State::Funded |
| Queries | return raw u8 | match 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
| File | Role |
|---|---|
contracts/escrow_v2.test.cam | Unit tests for the enum escrow |
contracts/escrow.test.cam | Same machine with u8 state codes |
contracts/escrow_two_vaults.invariant.cam | Multi-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 equalsLocker.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
| Pattern | Example |
|---|---|
| Shared identity key | Same m_id on both entities |
| Predict without storing | Locker.address(locker_id) |
| Child deploy | deploy Locker(locker_id) |
| Authenticate peer | from 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
| Entity | Shape | Role |
|---|---|---|
ERC20Votes | Token + votes | getVotes / transfers; feed for castVote |
TimelockController | Singleton | Schedule / execute / cancel with role from |
Governor | Singleton | Propose → 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
| Entity | Identity | Role |
|---|---|---|
ERC20 | m_token_id: u8 | Multi-instance test token; ERC20.address(0) / address(1) |
UniswapV2Pair | m_token0, m_token1 | Reserves, 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::keccak256Packedover an EIP-712 digest;m_noncesbumped in the matching transform (examples/uniswap-v2/ERC20.cam). - Wrapping TWAP math —
+%on cumulative prices (Checked vs Wrapping Arithmetic). Vecpush — factorym_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
| Function | Role |
|---|---|
min, max, minmax | Ordering |
abs, sign | Sign |
clamp | Range clamp |
muldiv, muldivmod | Widening multiply-then-divide (muldivmod returns quotient and remainder; EVM only — no Lean lowering, L16) |
divmod, divc, divr | Division variants |
modpow2, pow | Powers |
std::str
| Function | Role |
|---|---|
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_int | Aliases of parse_u64 / parse_i64 |
format | Rust-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
| Function | Role |
|---|---|
sha256 | SHA-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.
Related namespaces (not std::)
| Namespace | Docs |
|---|---|
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 / name | Use |
|---|---|
entity | Declare an entity |
extern | extern entity foreign surface |
routes / route | Route block / extern route stub |
view / init / private / accept | Route modifiers |
pure | Route modifier or start of pure fn |
fn | Second word of pure fn |
receive / fallback | Reserved route names on EVM (Receive and Fallback); not keywords |
where / from / throw | Preconditions / sender check / failure |
return | Return from a route |
call | Invoke a private route from an action list; also a test step |
deploy | Deploy another entity |
emit | Emit an event |
event / error | Declarations |
indexed | Event parameter indexing (EVM) |
library / using / for | Libraries, using Lib for T, expression/action for |
const / type / record / enum | Declarations |
macro | Entity macros (@name()) |
match / if / else | Control flow |
let / var | Bindings (var for capturing ~> returns) |
in | Member transforms; for x in …; fuzz ranges p in lo..hi |
as | Casts (x as u64) |
true / false | Boolean literals |
some / none | Option constructors |
array | Array literal array(a, b, c) |
identity | Identity member marker |
with | Send / deploy options; test and property initial state |
import / use | File import (import "./…" or bare import "token/core.cam") / namespace (use …) |
address_of | address_of Entity(args) — predicted CREATE2 address |
solidity_import | @solidity_import("…") on an extern entity |
test / property / fuzz / invariant | Testing (fuzz { … } is a property instance) |
expect / assume / bound / ctx / skip | Test / property vocabulary (skip from, skip if) |
track / derived / exclude / selectors / advanceTime | Invariant vocabulary |
cam_encode / effects | Reserved 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.
| Level | Operators | Notes |
|---|---|---|
| 0 | |params| body / lo..hi | Closure / 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 |
| 11 | as Type | Cast (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) |
| 14 | Primary | Literals, ^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
| Form | Where |
|---|---|
lo..hi | Exclusive range: for i in 0..n, fuzz bindings p in lo..hi, bound p in lo..hi |
lo..=hi | Inclusive 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:
| Form | Role |
|---|---|
=> | 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 2 | Chains where clauses, each with its own : throw |
: in c : throw N | Attaches 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
--target | Output |
|---|---|
evm (default) | Flat Solidity + Foundry harness |
lean | Lean 4 Lake project |
Flags
| Flag | Description |
|---|---|
-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 |
--check | Parse, validate and run target checks; write no files |
--check-lean | After Lean codegen, run lake build in the output directory |
--dump-ast | Parse, print the AST in Cambrian syntax, exit (single-file mode) |
--diagnostic-format human|json | human (default) or json: one cambrian.diagnostics/v1 JSON envelope on stdout (compiler identity + diagnostic list); progress lines are suppressed |
--max-errors N | Cap on recovered parse diagnostics (default 32) |
--version [--json] | Print the compiler build identity (--json: a cambrian.build-identity/v1 object) |
--help | Print 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
.camfiles 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.solfile. - Project mode (
--project p.yaml) reads the target, sources andoutput_dirfrom the YAML (relative to the YAML file).-o,--targetand--dump-astare 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 … --checkbefore shipping. --check-leanwith 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
| Code | Meaning |
|---|---|
0 | Success (warnings do not change the exit code) |
1 | Any 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
| Key | Type | Notes |
|---|---|---|
name | string | Optional; 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 |
target | string | Required — evm or lean |
output_dir | string | Default build/, relative to the YAML file |
sources | list of paths | Required — entry-point .cam files (entities, tests) |
library_paths | list of paths | Extra directories for shared .cam files. ${VAR} is expanded; then absolute or relative to the YAML. No built-in default. |
imports | list of paths | Shared helper files (not entities). Yaml directory first, then each library_paths entry. Same program-wide names as your own files. |
evm | object | EVM codegen knobs |
foundry | object | Foundry / solc settings |
fuzz | object | Default fuzz runs / seed |
invariant | object | Default invariant runs / depth |
lean | object | Lean 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 wayimport "…"does. They may containpure fn, types,library,event/error,extern entity, and further imports — notentity,test,fuzz, orinvariant. Put those undersources:.- Each path is tried next to the YAML, then in each
library_pathsdirectory, 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:andimports:is treated as a project source. import "./math.cam"/import "../lib/math.cam"stay relative to the importing file and do not searchlibrary_paths. A path without./or../(import "token/erc20-core.cam") tries the importer’s directory first, thenlibrary_paths.- Imported names are not namespaced. See Program Structure and the Contract Standard Library.
evm
| Key | Default | Notes |
|---|---|---|
allow_constructor_payable | true | When false, entity constructors are non-payable and the CambrianFactory deploys with value: 0 |
foundry
These keys are written into the generated foundry.toml.
| Key | Default | Notes |
|---|---|---|
solc_version | 0.8.24 | Compiler version |
evm_version | prague | EVM revision |
optimizer | false | Optimizer on/off |
optimizer_runs | 200 | Optimizer runs |
via_ir | false | IR pipeline |
fuzz_runs | fuzz.runs | Default 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
| Section | Key | Default | Notes |
|---|---|---|---|
fuzz | runs | 256 | Fuzz cases per test |
fuzz | seed | 0 | 0 = random; non-zero pins the RNG |
fuzz | max_local_rejects | 1024 | Written as Foundry max_test_rejects |
invariant | runs | 256 | Invariant runs |
invariant | depth | 50 | Calls per run |
invariant | fail_on_revert | false | Fail the run on any reverting call |
invariant | seed | 0 | 0 = 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
| Key | Default | Notes |
|---|---|---|
numerics | overflow-wrap | overflow-wrap (legacy alias bitvec), overflow-panic, or nat. Any other value is an error (F2) |
proof_helpers | true | false drops generated proof helpers (simp lemmas, proof scaffolding); theorems are then emitted as statements with sorry |
intrinsics | opaque | opaque or executable (computable, non-cryptographic hash models). Any other value is an error (F2) |
lean_toolchain | leanprover/lean4:v4.29.1 | Written to the generated lean-toolchain file |
See also
Codegen Mapping
How Cambrian constructs lower on the public targets.
Solidity-EVM (--target evm)
| Cambrian | Solidity |
|---|---|
entity E | Contract E (flat file, pragma solidity ^0.8.24) |
| members | Storage variables (+ transforms inlined into route bodies) |
| routes | External / public functions |
private routes / call r(...) | internal function _r, called directly (msg.sender stays the original caller) |
view routes | view functions |
msg::sender / msg::value | msg.sender / msg.value (payable when value is read) |
~> dest typed send | External call |
deploy E(...) / E.address(...) | CREATE2 through the generated CambrianFactory (deployE / predictE) |
init route | Deploy-time constructor stub + factory-only initialize(...) |
event / emit | Solidity event / emit |
error / throw Name | Custom errors / revert |
test / property / invariant | Foundry 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
| Cambrian | Solidity |
|---|---|
u8 … u128, U256 | uint8 … uint128, uint256 |
i8 … i128 | int8 … int128 |
bool, address | bool, address |
Address<E> | address |
String, bytes | string, bytes |
bytes32, pubkey | bytes32 |
Vec<T> | T[] |
HashMap<K, V> | mapping(K => V) |
Option<T> | Generated struct Option_<T> (tag + value) |
record R | struct R |
enum E | enum 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)
| Cambrian | Lean |
|---|---|
| entity state | structure State (plus ghost key lists for iterated maps when needed) and structure Identity |
| numeric types | BitVec n under the default lean.numerics: overflow-wrap (Nat under nat) |
| routes | E.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 / deploys | World updates (call, occupancy, balances) |
property / test | Theorems in <E>Spec.lean |
invariant | Action / 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
| Prefix | Concern |
|---|---|
| V | Entity / route / member / phase / deploy / match |
| E | EVM / Solidity compatibility |
| L | Lean target |
| T | test / property / fuzz |
| I | invariant |
| F | project.yaml / imports |
| W | Warnings / 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
| Code | Summary |
|---|---|
| V1 | Member transform in <route> names an unknown route |
| V2 | (warning) Route has no member transforms and an empty body |
| V3 | Unknown ^member, or a temporal cycle between members in one route |
| V4 | pure fn or library function reads an entity member, msg::*, sys::*, temporals, macros or impure evm::* (pass the value as a parameter) |
| V7 | Constant value is not a literal |
| V8 | Undefined identifier, function, macro or temporal reference |
| V9 | view route has member transforms, or (transitively) calls a route that has member transforms or state-changing actions |
| V10 | pure route has member transforms |
| V11 | pure route uses an impure construct (msg::*, sys::*, members, sends, deploys, call, …) |
| V12 | More than one init route, or an init route with a return type |
| V13 | Duplicate phase name |
| V14 | Unphased transform on a phased route, or phased transform on an unphased route |
| V15 | Transform references an unknown phase |
| V16 / V17 | identity member declares transforms / a default value |
| V20 | Empty string "" compared with an address |
| V22 | ns::name(...) without the matching use import — often a send misspelt as Entity::route(args) ~> dest (write route(args) ~> dest) |
| V23 | Named 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 |
| V24 | var x = r(...) ~> dest targets a route with no return type |
| V25 | var shadows a parameter or member, or is defined twice |
| V26 | var used in a member transform of the same or an earlier phase (or an unphased route) |
| V27 | Route-level where references a var defined in the body |
| V28 | Per-phase where references a var from the same or a later phase |
| V29 | Forbidden action inside an action-level for (var capture, return) |
| V30 / V31 | Duplicate extern entity (or clash with an entity) / duplicate extern route |
| V32 | deploy E(args) arity ≠ identity members + init-route params |
| V33 | EVM — from E(args) arity ≠ identity-member count of E |
| V34 / V35 | emit of an undeclared event / wrong arity or types |
| V36 | EVM — event with more than three indexed parameters |
| V38 / V39 | throw Name of an undeclared error / wrong arity or types |
| V40 / V41 | EVM — receive / fallback wrong shape (no params, no return, not view/pure; receive needs accept) / declared twice |
| V42 | return(value) in a route without -> T (bare return is fine) |
| V43 | ^member outside a member transform body |
| V45 | Several 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 |
| V48 | if without else where a value is required |
| V49 | Bare stdlib call (min, pow, sha256, …) without the std::math:: / std::str:: / std::crypto:: prefix |
| V50 | Transform assigns an incompatible type (string ↔ number needs std::str::parse_* / format; address vs Address<E>) |
| V51 | Unused let in a route or pure fn body |
| V52 | Record cycle without Option / Vec / HashMap indirection, or a type alias that never unfolds |
| V53 | Nested pattern in let some(<pat>) (use an identifier or _) |
| V54 | Route body mixes phase blocks and bare actions |
| V55 | using method name collides with a built-in method of the receiver type |
| V56 | using names an undeclared pure fn or library |
| V57 | using function’s first parameter type doesn’t match the for type |
| V58 | Invariant deploy { … } must contain exactly one address |
| V59 | deploy { … } on a multi-entity invariant |
| V60 | Several from alternatives with different : throw annotations |
| V62 | EVM — 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 |
| V63 | EVM — 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 |
| V65 | Arithmetic (+ - * / %, wrapping variants) on an Option<_> operand |
| V66 | Non-exhaustive match without a _ / binding arm: a missing enum variant, some / none, true / false, or integer-literal arms with no catch-all |
| V67 | Route or pure fn with -> T never returns a value (and never throws), or returns a value of a different category (numeric / String / bool) |
| V68 | Record literal R { … } sets a field twice, names an unknown field, or omits a field (use base { field: v } to update an existing value) |
| V69 | Signed 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 |
| V70 | Member 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
| Code | Summary |
|---|---|
| E05 | (warning) accept on a regular route has no EVM meaning (payability is inferred); accept receive() is exempt |
| E07 | Solidity — 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 |
| E16 | EVM — namespace call with no EVM lowering (including an unknown evm:: intrinsic name or msg:: field), or encode<…>(…) outside tests |
| E17 | Solidity — unrecognised HashMap transform shape; use m.insert / m.update / m.remove (optionally inside if / block / let) |
| E18 | Solidity — tuple type outside a multi-return position; use a record |
| E19 | Solidity — generic type other than Vec / HashMap / Option |
| E20 | Solidity — type name that is not a primitive, record, enum or alias |
| E21 | Solidity — unsupported as target (allowed: u8–U256, i8–i128, bool, address, pubkey) |
| E22 | EVM — typed send to an entity that is neither in the program nor an extern entity |
| E23 | Solidity — mixed-type tuple destructure whose slot types can’t be inferred |
| E25 | EVM — std::* call outside the standard-library allowlist |
| E27 | Reported 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 |
| E28 | EVM — 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 |
| E29 | Solidity — view route body contains a send, value transfer, deploy or emit (Lean accepts them) |
L — Lean
| Code | Summary |
|---|---|
| L1 | Generic type other than Vec / HashMap / Option |
| L2 | Closure as a value, or unsupported fold accumulator |
| L5 | expect throw N on a route that can never fail (vacuous theorem) |
| L6 | expect return … on a route without a return type |
| L7 | (warning) skip from is not honoured on Lean; from checks stay |
| L8 | Typed send whose destination doesn’t statically resolve to an in-program entity |
| L9 | var 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 |
| L11 | Fire-and-forget self-send to a failing route from a route that can’t fail |
| L13 | std::crypto::* (e.g. sha256) has no Lean lowering |
| L14 | Type name that is not a primitive, record, enum or alias |
| L15 | Unrecognised HashMap transform shape (Lean mirror of E17) |
| L16 | Namespaced call or msg:: / sys:: field with no Lean lowering (e.g. std::math::muldivmod, an unknown evm:: name) |
T — tests, properties, fuzz
| Code | Summary |
|---|---|
| T1 | for Entity names an unknown entity |
| T2 / T3 | call of an unknown route / wrong arity |
| T4 | with { … } / expect state { … } names a non-member field |
| T5 | expect before any call |
| T6 | Test with no call |
| T8 | (warning) Unknown context-block namespace (known: msg, sys) |
| T10 | Property parameter collides with a member, or a let shadows a parameter |
| T11 | bound in a property body, bad instance binding, or bound / assume in a plain test |
| T12 | assume expression is not boolean, or used in a plain test |
| T15 | skip if outside an invariant action body |
| T16 | advanceTime(...) outside an invariant action body (needs #[with_time]) |
| T17 | Instance binding shape: test needs p: value, fuzz needs p in lo..hi |
| T18 | test instance doesn’t bind every property parameter |
| T19 | field: * names a non-member |
| T20 | Unknown name in ctx { … } (allowed: msg::{sender,value}, sys::{now,timestamp,chainid,block_number,balance}) |
| T21 | Field both pinned and * in the same with { … } |
| T22 | msg:: / sys:: field in with { … } / init { … } instead of ctx { … } |
| T23 | Context parameter declared twice in one ctx { … } |
| T24 | Test deploy b = E(...) names an unknown entity |
| T25 | Test deploy binding name already in use |
| T26 | Test deploy constructor arity mismatch |
| T27 | Test deploy initial state names a non-member |
| T28 | call b.route(...) on a name not bound by an earlier deploy |
| T29 | call b.route(...): unknown route or wrong arity |
| T30 | Duplicate test let, let shadowing a parameter, or let x: T with a non-assignable initializer |
| T31 | Empty fuzz range for a parameter |
| T36 | expect emit in a property (only allowed in test) |
| T38 | Boolean expect that isn’t boolean-shaped, or mentions result after a route with no return type / when result is already a name in scope |
| T39 | Solidity — 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
| Code | Summary |
|---|---|
| I1 | action names an unknown route |
| I2 | action parameters don’t match the route (arity error; type mismatch is a warning) |
| I3 | check is not boolean |
| I4 | Forbidden statement in an action body (only bound / assume / skip if / advanceTime) |
| I5 | Invariant needs at least one action and one check |
| I6 | deploy { … } / senders { … } entry is not an address literal or constant |
| I8 | Duplicate instance name |
| I9 | Unknown instance entity, or action inst.route on an undeclared instance |
| I10 | init inst { … } names a non-member |
| I11 | Unqualified member in a multi-entity invariant (write inst.member) |
| I12 | derived body contains something other than let |
| I13 | Duplicate track name |
| I14 | exclude selectors { … } names an undeclared action |
| I15 | trace::* names an undeclared action or unknown accessor (length / count / lastWas) |
| I16 | trace::* outside an invariant assume / check |
| I17 | Solidity — check expression can’t be lowered for Foundry; move the logic into a view route |
| I18 | EVM — multi-instance invariant: several instances of an entity without identity members, duplicate identity values, or a cyclic deploy order |
F — project configuration and imports
| Code | Summary |
|---|---|
| F2 | Unknown lean.numerics value (expected overflow-wrap, overflow-panic, nat) or lean.intrinsics value (expected opaque, executable) |
| F4 | Imported file (import "…" or yaml imports:) declares entity / test / fuzz / invariant — list it under sources: |
| F5 | yaml imports: path or bare import "…" not found next to the importer or in any library_paths root |
| F6 | target: 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
| Code | Summary |
|---|---|
| W1 | Member has no transforms |
| W2 | pure fn is never called |
| W3 | Entity constant is never used |
| W4 | Duplicate use import |
| W5 | deploy of an entity not defined in the program |
| W7 | Fuzz parameter type uses a fallback strategy on this target |
| W8 | test instance leaves a field: * unpinned (falls back to the default); also a file imported twice |
| W9 | Entity-local record / enum / type shadows a program-scope one (they are distinct types) |
| W10 | EVM — route updates a balance/allowance-like member without an emit |
| W11 | In a test, call of the init route is followed by msg { sender: … }; set the sender first |
| W13 | Invariant bootstraps the constructor without an explicit deploy { … } |
| W14 | Invariant deploy address is also listed in senders |
| W15 | Invariant 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 evmthenbash setup.sh && forge testin the output directory. - Lean:
--target leanthenlake 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.