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.
Why Cambrian?
Many systems need explicit state, clear entry points, and disciplined communication between components. General-purpose languages leave those concerns scattered across imperative updates and ad-hoc APIs. Cambrian makes entity state, routes, and inter-entity messages part of the syntax.
There is no hidden control flow and no ambiguity about what an entity does when a route runs. Specs live in the same source: test, property / fuzz, and invariant blocks lower to Foundry tests or Lean theorems depending on the target.
Smart contracts are a common use case (durable state, multi-party messaging, expensive mistakes), but they are not the only one. The model fits anywhere you want member-centric state and message-shaped interfaces.
Key Principles
Member-Centric State
Members are named, typed fields that belong to an entity. Each member declares its own transformation rules inline, specifying exactly how it changes in response to each route. This inverts the traditional pattern where a function body scatters state updates across imperative statements.
m_balance: U256 {
in deposit(amount) => m_balance + amount
in withdraw(amount) => m_balance - amount
}
Routes
Routes are the named entry points of an entity. They declare parameters, optional preconditions, and the actions that run when the route is invoked.
routes {
deposit(amount: U256) => []
withdraw(amount: U256)
where (m_balance >= amount) : throw InsufficientBalance()
=> [
emit Withdrawal(msg::sender, amount)
]
getBalance() -> U256 => [
return(m_balance)
]
}
Temporal References
The temporal reference operator ^x refers to a member’s value after its transform for the current route has been applied. Use it when another member’s transform (or a later action) must see updated state, not the pre-transform snapshot.
m_count: u64 {
in increment() => m_count + 1
}
m_total: u64 {
in increment() => m_total + 1
}
m_over_limit: bool {
// Without ^, m_count / m_total would still be the old values.
in increment() => ^m_count > 100 || ^m_total > 1000
}
Pure Functions
Cambrian distinguishes state-modifying routes from pure functions. Pure functions cannot access or modify entity state; they operate solely on their inputs and produce a deterministic output.
pure fn max(a: u64, b: u64) -> u64 {
if a > b { a } else { b }
}
Message-Based Routing
Inter-entity communication uses the send operator ~> to dispatch typed messages. On EVM this becomes a call (with optional value); on Lean it is a world-state transition.
Transfer(to, amount) ~> Token.address()
Properties and Invariants
Verification is part of the language, not a separate toolchain glue layer.
A property is a parameterized statement about an entity, with nested concrete test instances and sampling fuzz instances:
property "increment from zero" (amount: u64) for Counter with { m_count: 0 } {
call increment(amount)
expect state { m_count: amount }
fuzz { amount in 0..1000 }
}
An invariant explores random traces of routes and checks predicates after each step:
invariant "count stays non-negative" for Counter {
init { m_count: 0 }
action increment(amount: u64) {
bound amount in 0..1000
}
action reset() { }
check m_count >= 0
}
Plain test blocks cover fixed scenarios. The same declarations feed Foundry and Lean harnesses — see Testing and Verification.
Architecture
.cam source (+ optional project.yaml)
|
v
Cambrian Transpiler
|
+---> --target evm → Solidity + Foundry
|
+---> --target lean → Lean 4 Lake project
The transpiler parses .cam files (or a multi-file project.yaml), validates them for the selected target, and emits backend-specific artifacts.
What This Book Covers
- Getting Started — install the toolchain and compile a Counter to EVM and Lean.
- Language Guide — entities, routes, types, events, errors,
std::, shipped contract components, and multi-file projects. - Advanced Topics — phased routes, identity members, deterministic addresses.
- Targets — what each backend emits and how to run it.
- Testing and 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 built from the proprietary cambrian-lang
workspace (see Prerequisites for access). It uses the
standard Rust toolchain. After prerequisites and a successful build, you can
transpile .cam programs to the public targets evm and lean.
Steps
- Prerequisites — Rust via rustup; optional Foundry
and Lean 4 + Lake for EVM / Lean targets; access to
cambrian-lang. - Building from Source — from the workspace root, run
cargo build --releaseand putcambrian-transpileron yourPATH. - Editor Setup (optional) — associate
.camfiles and install the TextMate grammar for VS Code / Cursor.
Once the binary works, continue with the Quick Start.
Prerequisites
Install only what you need for the targets you plan to use. The transpiler itself requires Rust. EVM and Lean tooling are optional until you exercise those backends.
Rust (required)
Cambrian is a Rust workspace. Install the toolchain with rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Confirm rustc and cargo are on your PATH:
rustc --version
cargo --version
Keep the toolchain current with rustup update.
Access to the transpiler (cambrian-lang)
The Cambrian transpiler and reference contracts live in the cambrian-lang
workspace. That tree is not open source and is not available as a public
download — there is no public clone URL or release archive.
Cambrian is developed in a proprietary mode. If you are interested in collaborating with the development team or obtaining access to the toolchain, contact [TBD].
Once you have the workspace on your machine, build from its root (see
Building from Source). Sample contracts such as
contracts/counter.cam are in that tree.
Foundry (for --target evm)
The EVM backend emits Solidity (^0.8.24) and, when the program includes
test / fuzz / invariant blocks, a Foundry project (foundry.toml,
setup.sh, and tests under test/).
Install Foundry so forge is available:
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
Lean 4 and Lake (for --target lean)
The Lean backend emits a Lake project (lakefile.toml, pinned
lean-toolchain, and generated modules under Cambrian/). Install Lean 4
via elan:
curl https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh -sSf | sh
elan --version
lake --version
The generated lean-toolchain file pins the Lean version for that output;
Lake will fetch it on first lake build.
What you do not need
Node.js is not required for building the transpiler or for the evm or
lean workflows described in this book.
Checklist
| Component | Needed for | Check |
|---|---|---|
| Rust / Cargo | Building the transpiler | rustc --version |
Foundry (forge) | Running EVM tests | forge --version |
| elan / Lake | Building Lean output | lake --version |
Next: Building from Source.
Building from Source
These steps assume you already have the cambrian-lang workspace (proprietary;
not a public download — see Prerequisites).
Build the transpiler from the workspace root.
Build
cd cambrian-lang
cargo build --release
The binary is:
target/release/cambrian-transpiler
Optionally put it on your PATH:
ln -s "$(pwd)/target/release/cambrian-transpiler" ~/.local/bin/cambrian-transpiler
Invoke it as ./target/release/cambrian-transpiler from the repo, or as
cambrian-transpiler once it is on PATH. The examples below assume the
binary is on PATH.
CLI usage
cambrian-transpiler <input.cam> [-o <output_dir>] [--target evm|lean]
[--dump-ast] [--source-map] [--check-lean]
cambrian-transpiler --project <project.yaml> [--check-lean]
| Flag | Meaning |
|---|---|
-o <dir> | Write generated files under <dir>. Default: build/<Entity>-entity/. |
--target <name> | Backend: evm (default) or lean. |
--project <yaml> | Load a multi-file project from project.yaml (sources, optional library_paths / imports, target, output dir). |
--dump-ast | Pretty-print the parsed program and exit (no codegen). |
--source-map | Emit .cam.map JSON alongside generated code (where supported). |
--check-lean | After --target lean, run lake build in the output directory. |
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, or set up Editor Support.
Quick Start
This path takes you from a blank .cam file to generated EVM Solidity and a
Lean project, using the same Counter example that ships in cambrian-lang.
What you will build
A Counter entity with three routes:
- increment — add to the stored count
- reset — set the count to zero
- getCount — return the current count
You will see entities, routes, and member transforms, then transpile the same source to more than one backend.
Steps
- 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, not a simplified
stub.
The entity
entity Counter {
routes {
increment(amount: u64) => []
reset() => []
getCount() -> u64 => [
return(m_count)
]
}
m_count: u64 {
in increment(amount) => m_count + amount
in reset() => 0
}
}
Create your own copy if you prefer editing outside the repo:
cp contracts/counter.cam ~/counter.cam
Cambrian sources use the .cam extension.
Entity
An entity is the top-level unit of stateful behaviour: named members plus
a public route interface. One .cam file may declare several entities; the CLI
processes the first entity in a single-file invocation, while multi-entity
programs use --project and a project.yaml.
entity Counter {
// routes and members...
}
The name Counter becomes the Solidity contract name on --target evm and
the generated module name on Lean.
Routes
Routes are the public entry points. On EVM they lower to external / public functions; the source form is always a route with an action list.
routes {
increment(amount: u64) => []
reset() => []
getCount() -> u64 => [
return(m_count)
]
}
| 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/
src/
Counter.sol # Solidity ^0.8.24 contract
The transpiler prints a reminder such as
cd /tmp/counter-evm && bash setup.sh && forge test. With only the
entity source (no test / fuzz / invariant declarations), that Foundry
scaffolding is not emitted—you still have a readable Counter.sol to compile
or deploy with your own Foundry project.
With Cambrian tests (Foundry harness)
To generate foundry.toml, setup.sh, and test/Counter.t.sol, include
contracts/counter.test.cam via a project file. Source paths are resolved
relative to the YAML file’s directory.
From the cambrian-lang root, write counter-evm.yaml:
name: counter
target: evm
output_dir: /tmp/counter-evm
sources:
- contracts/counter.cam
- contracts/counter.test.cam
./target/release/cambrian-transpiler --project counter-evm.yaml
cd /tmp/counter-evm
bash setup.sh # installs forge-std into lib/ if missing
forge test
setup.sh runs forge install foundry-rs/forge-std --no-commit when needed,
then prints the usual forge test / profile hints.
Lean: same source, Lake project
./target/release/cambrian-transpiler contracts/counter.cam \
-o /tmp/counter-lean --target lean
cd /tmp/counter-lean && lake build
Or let the CLI run Lake for you:
./target/release/cambrian-transpiler contracts/counter.cam \
-o /tmp/counter-lean --target lean --check-lean
Typical layout (for contracts/counter.cam on current transpilers):
/tmp/counter-lean/
lakefile.toml
lean-toolchain
Cambrian.lean
Cambrian/
SimpAttrs.lean
Prelude.lean # bundled prelude (Core / Evm pieces live here)
Generated/
World.lean
Counter.lean
CounterRoutes.lean
CounterSpec.lean # when test / property sources are included
...
Older docs sometimes listed separate Core.lean / Evm.lean files; those
modules are vendored inside Prelude.lean for entity-only emits.
Adding counter.test.cam through --project (with target: lean) also
emits specification modules derived from those tests.
Useful flags while iterating
# See the parsed program without writing files
./target/release/cambrian-transpiler contracts/counter.cam --dump-ast
# Emit source maps next to generated code (supported backends)
./target/release/cambrian-transpiler contracts/counter.cam \
-o /tmp/counter-evm --target evm --source-map
Next steps
- Editor Setup for
.camhighlighting - Language Guide for the full surface
- Targets for per-backend details
Editor Setup
Syntax highlighting for .cam files is optional but useful. The grammar lives
in the cambrian-lang tree under cambrian-syntax/.
VS Code / Cursor (TextMate)
The extension is a local TextMate package (package.json +
syntaxes/cambrian-clean.tmLanguage.json). It registers the cambrian
language for the .cam extension.
Install from the repo
# From your cambrian-lang checkout
cp -R cambrian-syntax ~/.vscode/extensions/cambrian-syntax
For Cursor, use the corresponding extensions directory if it differs from
~/.vscode/extensions (for example under ~/.cursor/extensions).
Reload the window (Developer: Reload Window from the Command Palette).
Verify
Open any .cam file. The status bar should show Cambrian. If not:
- Command Palette → Change Language Mode
- Choose Cambrian
Or add a workspace association in .vscode/settings.json:
{
"files.associations": {
"*.cam": "cambrian"
}
}
What you get
- Highlighting for keywords (
entity,routes,in,return,pure,fn,let,match,test, …), types, and comments - Bracket matching and comment toggle (
Cmd+//Ctrl+/) - Automatic
.cam→ Cambrian language mode
Other editors
There is no separate plugin for every editor. Practical options:
- Point the editor at the TextMate grammar
cambrian-syntax/syntaxes/cambrian-clean.tmLanguage.jsonif it supports TextMate / tmLanguage bundles. - Associate
*.camwith Rust or similar C-family highlighting as a fallback; braces,let, and type annotations read reasonably well.
Next steps
Return to the Quick Start or jump into the Language Guide.
Program Structure
A Cambrian source file uses the .cam extension. It describes one or more
entities — units of persistent state that communicate through messages —
plus optional shared declarations (types, libraries, events, tests, and so on).
Imports
Cambrian has two unrelated import-style constructs.
Multi-file import "path.cam"
Share declarations across files with a string-literal path. An explicit
./ or ../ path is resolved only relative to the importing .cam file.
A bare path (import "token/core.cam") tries that directory first,
then each library_paths root from project.yaml:
// math.cam — library file: declarations only, no entities
pure fn min(a: u256, b: u256) -> u256 { if a < b { a } else { b } }
library SafeMath {
pure fn add(a: u256, b: u256) -> u256
where (a + b >= a) : throw Overflow() { a + b }
}
// Vault.cam
import "./math.cam"
using SafeMath for u256;
entity Vault {
routes {
deposit(amount: u256) => [
m_balance := m_balance.add(amount)
]
}
m_balance: u256 {
in deposit(amount) => m_balance.add(amount)
}
}
Semantics:
- Imports are transitive; cycles are rejected at load time.
- Importable:
pure fn,record,enum,type,const,event,error,extern entity,library,using … for …;, and otherimports. - 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 / const | Shared types and constants |
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
import "./shared.cam"
error Unauthorized();
event Deposited(indexed who: address, amount: U256);
pure fn max(a: u64, b: u64) -> u64 {
if a >= b { a } else { b }
}
entity Counter {
routes {
init setup(owner: address) => []
deposit(amount: U256)
where amount > 0 : throw Unauthorized()
=> [
emit Deposited(msg::sender, amount)
]
view get_balance() -> U256 => [
return(m_balance)
]
}
m_owner: address {
in setup(owner) => owner
}
m_balance: U256 {
in setup(_) => 0
in deposit(amount) => m_balance + amount
}
}
Ordering inside an entity
- Routes live in a single
routes { }block. - Members are declared in the entity body alongside that block.
- Type aliases, records, enums, constants, and macros may also appear inside the entity.
Multiple entities
One file (or one project) can define several entities that send to each other
via typed addresses and ~>:
entity Ledger {
routes {
credit(account: address, amount: U256) => []
}
m_balances: HashMap<address, U256> {
in credit(account, amount) => {
let current = m_balances.get(account).unwrap_or(0);
m_balances.set(account, current + amount)
}
}
}
entity Shop {
routes {
purchase(item_id: u64)
where msg::value > 0 : throw Unauthorized()
=> [
credit(msg::sender, msg::value) ~> m_ledger
]
}
m_ledger: Address<Ledger> {
in init_shop(ledger) => ledger
}
}
Naming conventions
These are conventions, not compiler rules (identifiers are
[A-Za-z_][A-Za-z0-9_]*):
| 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 accessed by index or via
destructuring in let bindings:
let pair: (u64, bool) = (42, true);
Tuples are most useful as return types for pure functions and as intermediate values in expressions.
Type Annotations
Type annotations appear after a colon in variable bindings, function parameters, member declarations, and return types:
let count: u64 = 0;
pure fn add(a: u64, b: u64) -> u64 {
a + b
}
In many positions, such as let bindings in blocks, the type can be inferred
by the compiler.
Type Casts
The as keyword performs explicit type conversion between compatible types:
let small: u8 = 255;
let big: u64 = small as u64;
Type casting is especially common when working with mixed-width integer arithmetic. See Arithmetic and Logic for details on checked and wrapping operations across types.
Primitive Types
Cambrian provides built-in primitives for booleans, integers, text, bytes, and addresses.
Boolean
| 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 | Platform-sized | 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 | Alias | 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) |
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 | Still a language type (EVM storage maps it like a wide integer). It is not part of the everyday EVM message/system context — prefer address and msg::sender for access control. |
Summary
| Type | Common use |
|---|---|
bool | Flags, conditions |
u8…u128, i8…i128 | Counters, IDs, offsets |
U256 / uint256 | 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 |
v.push(elem) | Append (as the tail of a Vec member transform on EVM) |
v.get(index) | Option<T> |
v.is_empty() | Emptiness |
let empty: Vec<u64> = array();
let nums: Vec<u64> = array(1, 2, 3);
HashMap<K, V>
Key-value map (keys are hashable primitives / address):
m_balances: HashMap<address, U256> {
in transfer(from, to, amount) => {
let sender_bal = m_balances.get(from).unwrap_or(0);
let receiver_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(from, sender_bal - amount)
.set(to, receiver_bal + amount)
}
}
| Method | Description |
|---|---|
m.get(key) | Option<V> |
m.set / m.update / m.insert | Write |
m.exists(key) | Presence |
m.remove(key) | Delete |
m[key] | Direct access |
let scores: HashMap<address, u64> = {};
Option<T>
some(value) / none:
let result = match m_data.get(key) {
some(val) => val * 2,
none => 0
};
Address<Entity>
Typed wrapper around address that carries the target entity at compile time.
Named messages require a typed destination (V23 on a plain address).
Plain value transfers (~> dest with { value }) may use untyped address.
m_root: Address<RootToken> // named sends OK
m_backup: address // value transfer only
Predicting addresses (EVM)
With deterministic_addresses: true on the EVM target, compute a destination
without a manual factory:
macro pairAddr(token0: address, token1: address) -> Address<UniswapV2Pair> = {
UniswapV2Pair.address(token0, token1)
}
routes {
notify(token0: address, token1: address) => [
Ping() ~> UniswapV2Pair.address(token0, token1)
]
}
Entity.address(args) and the addressOf(...) spelling lower to the same
CREATE2 expression on EVM. Singletons use Entity.address() with no arguments.
See Deterministic Addresses and
Deploy.
| Feature | Address<Entity> | address |
|---|---|---|
| Named send | Allowed | 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 and can be used as member types, route parameters, and intermediate values.
Declaration
A record is declared with the record keyword followed by named, typed fields:
entity Marketplace {
record Listing {
seller: address,
price: U256,
active: bool
}
record Bid {
bidder: address,
amount: U256,
timestamp: u64
}
}
Each field has a name and a type, separated by a colon. Fields are separated by commas.
Construction
Records are constructed by providing values for all fields:
let listing = Listing {
seller: msg::sender,
price: 1000,
active: true
};
Every field must be specified – there is no default value mechanism. The order of fields in the constructor does not need to match the declaration order, but all fields must be present.
Field Access
Fields are accessed with dot notation:
let price = listing.price;
let who = listing.seller;
if listing.active {
// handle active listing
}
Functional Update
Cambrian supports a functional update syntax that creates a new record value with some fields changed while keeping the rest:
let updated = listing { active: false };
This produces a new Listing with active set to false and seller and
price carried over from the original. The original listing is not mutated.
Functional update is especially useful in member transforms, where you need to produce a new state value based on the current one:
m_listing: Listing {
in create(seller, price) => Listing {
seller: seller,
price: price,
active: true
}
in deactivate() => m_listing { active: false }
in update_price(new_price) => m_listing { price: new_price }
}
You can update multiple fields at once:
let revised = listing {
price: new_price,
active: true
};
Records as Member Types
Records are commonly used as the types of members to group related state:
entity Escrow {
record Deal {
buyer: address,
seller: address,
amount: U256,
released: bool
}
routes {
init create(buyer: address, seller: address, amount: U256) => []
release()
from Buyer(_)
where m_deal.released == false : throw 101
=> []
}
m_deal: Deal {
in create(buyer, seller, amount) => Deal {
buyer: buyer,
seller: seller,
amount: amount,
released: false
}
in release() => m_deal { released: true }
}
}
Records in Collections
Records can be stored in generic collections:
m_bids: Vec<Bid> {
in place_bid(amount) => m_bids.push(Bid {
bidder: msg::sender,
amount: amount,
timestamp: sys::now
})
}
Nesting
Records can contain other records as field types:
record Config {
fee_rate: u64,
limits: Limits
}
record Limits {
max_deposit: U256,
min_deposit: U256
}
Access nested fields with chained dot notation:
let max = config.limits.max_deposit;
Enums
Enums define a type with several named variants. Variants may be unit-only or carry payload data. Declare them at program scope or inside an entity.
Unit variants
enum Status {
Pending,
Active,
Closed,
Cancelled
}
Data variants
enum Action {
Transfer(U256),
UpdateConfig(u64, u64),
Pause
}
Construction and match
let status = Status::Active;
let action = Action::Transfer(1000);
let label = match status {
Status::Pending => "waiting",
Status::Active => "live",
Status::Closed => "done",
Status::Cancelled => "cancelled"
};
let amount = match action {
Action::Transfer(amount) => amount,
Action::UpdateConfig(a, b) => a + b,
Action::Pause => 0
};
Use _ as a catch-all. Duplicate / unreachable arms after _ are rejected
(V47).
Members and where
enum Phase {
AwaitingDeposit,
Funded
}
m_phase: Phase {
in create(_, _) => Phase::AwaitingDeposit
in fund() => Phase::Funded
}
routes {
bid(amount: U256)
where m_phase == Phase::Funded : throw NotFunded()
=> []
}
EVM lowering (tagged unions)
Solidity has no native sum type. Payload-bearing enums lower to a tagged-union pair:
- an enum
<Name>_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 inside an entity:
entity Token {
type Amount = U256;
type AccountId = address;
type Timestamp = u64;
}
After declaration, the alias can be used anywhere the original type is expected. The two names are fully interchangeable – no conversion is needed.
Usage
Aliases make member declarations, route signatures, and record fields more expressive:
entity Token {
type Amount = U256;
type AccountId = address;
record Transfer {
from: AccountId,
to: AccountId,
value: Amount
}
routes {
transfer(to: AccountId, amount: Amount)
where m_balances[msg::sender] >= amount : throw 100
=> []
}
m_total_supply: Amount {
in mint(_, amount) => m_total_supply + amount
}
m_balances: HashMap<AccountId, Amount> {
in transfer(to, amount) => {
let from_bal = m_balances[msg::sender];
let to_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(msg::sender, from_bal - amount)
.set(to, to_bal + amount)
}
}
}
Aliases for Complex Types
Aliases are particularly valuable when the underlying type is verbose:
type Approvals = HashMap<address, HashMap<address, U256>>;
type Voters = Vec<address>;
type PriceOracle = Address<Oracle>;
Without aliases, these types would need to be repeated in full wherever they appear, making the code harder to read and maintain.
Scope
Type aliases declared inside an entity are scoped to that entity. They are not visible in other entities within the same file. If two entities need the same alias, each must declare its own.
Aliases vs. Newtypes
Type aliases are transparent – the compiler treats the alias and the original type as identical. This means you cannot use aliases to prevent accidental mixing of conceptually different values:
type UserId = u64;
type ProductId = u64;
// The compiler allows this because both are u64:
let user: UserId = 1;
let product: ProductId = user; // no error
If you need distinct types that the compiler will not conflate, consider using single-field records instead:
record UserId { value: u64 }
record ProductId { value: u64 }
Literals
Literals are fixed values written directly in source code. Cambrian supports numeric literals in several bases, string and byte literals, booleans, collection constructors, and optional values.
Numeric Literals
Decimal
Plain decimal integers are the most common form:
let count = 42;
let large = 1000000;
Underscore Separators
Underscores can appear anywhere within a numeric literal to improve readability. They are ignored by the compiler:
let million = 1_000_000;
let fee = 100_000;
let precise = 1_234_567_890;
Hexadecimal
Hexadecimal literals start with 0x or 0X:
let flags = 0xFF;
let mask = 0xDEAD_BEEF;
Binary
Binary literals start with 0b or 0B:
let bits = 0b101010;
let byte_val = 0b1111_0000;
String Literals
String literals are enclosed in double quotes and produce String values:
let greeting = "hello";
let name = "Cambrian";
let empty_str = "";
Standard escape sequences are supported within string literals.
Byte Literals
Byte literals are prefixed with b and produce bytes values:
let raw = b"raw data";
let payload = b"message body";
Byte literals are useful when working with raw binary data that should not be interpreted as UTF-8 text.
Boolean Literals
The two boolean values:
let active = true;
let paused = false;
Collection Literals
Empty HashMap
An empty HashMap is written with empty braces:
let scores: HashMap<address, u64> = {};
Vec (Array)
Vectors are created with the array function. It accepts zero or more
comma-separated values:
let empty: Vec<u64> = array();
let nums = array(1, 2, 3);
let addrs = array(addr1, addr2);
Optional Literals
The Option<T> type has two constructors:
some
Wraps a value in Option:
let found = some(42);
let user = some(msg::sender);
none
Represents the absence of a value:
let missing: Option<u64> = none;
Record Literals
Records are constructed by specifying values for all fields:
record Transaction {
id: u64,
value: U256,
sender: address
}
let tx = Transaction {
id: 1,
value: 100,
sender: msg::sender
};
See Records for full details on record construction and functional update syntax.
Summary
| Literal 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 | HashMap<K,V> | {} |
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.get(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"
};
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 }
}
Iteration (for)
Expression-level for produces a value (typically a Vec) by mapping
over an iterator:
pure fn double_all(xs: Vec<u32>) -> Vec<u32> {
for x in xs { x * 2 }
}
pure fn first_n(n: u32) -> Vec<u32> {
for i in 0..n { i }
}
Common iterator sources:
| 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:
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:
pure fn safe_add(a: U256, b: U256) -> U256 {
a + b // checked -- reverts if overflow
}
pure fn hash_combine(a: u64, b: u64) -> u64 {
(a *% 31) +% b // wrapping -- intentional modular arithmetic
}
Comparison Operators
| 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) |
*, /, % | |
+, - | |
<<, >> | |
& | |
^ | |
| | |
==, !=, <, <=, >, >= | |
&& | |
| Lowest | || |
Use parentheses to override precedence when the intent is not obvious:
let result = (a + b) * (c - d);
let check = (x > 0) && (y < max);
Control Flow
Cambrian provides two control flow expressions: if/else for conditional
branching and match for pattern matching. Both are expressions – they
evaluate to a value.
if / else
The if/else expression evaluates a boolean condition and returns the value
of the corresponding branch:
let fee = if amount > threshold { high_fee } else { low_fee };
Basic Form
if condition {
value_when_true
} else {
value_when_false
}
Both branches must produce values of the same type when used as an expression.
Chained Conditions
Multiple conditions can be chained with else if:
let tier = if amount >= 10000 {
"gold"
} else if amount >= 1000 {
"silver"
} else {
"bronze"
};
In Member Transforms
if/else is frequently used in member transforms to compute new state
conditionally:
m_high_score: u64 {
in submit(score) => if score > m_high_score {
score
} else {
m_high_score
}
}
In Pure Functions
Pure functions commonly use if/else as their body:
pure fn max(a: u64, b: u64) -> u64 {
if a >= b { a } else { b }
}
pure fn clamp(val: u64, lo: u64, hi: u64) -> u64 {
if val < lo { lo }
else if val > hi { hi }
else { val }
}
match
The match expression compares a value against a series of patterns and
executes the first matching branch:
let description = match status {
Status::Active => "running",
Status::Paused => "paused",
Status::Closed => "finished",
_ => "unknown"
};
Syntax
match expression {
Pattern1 => result1,
Pattern2 => result2,
_ => default_result
}
Each arm consists of a pattern, the => arrow, and a result expression.
Arms are separated by commas.
Matching Enum Variants
The most common use of match is destructuring enums:
enum Command {
Deposit(U256),
Withdraw(U256),
Freeze
}
let response = match cmd {
Command::Deposit(amount) => amount,
Command::Withdraw(amount) => amount,
Command::Freeze => 0
};
The variable names in data variant patterns (like amount above) bind the
associated data for use in the result expression.
Matching Option
Option<T> is commonly matched to handle present and absent values:
let balance = match m_balances.get(account) {
some(val) => val,
none => 0
};
Block Bodies
When a match arm needs multiple computations, use a block:
let result = match action {
Action::Transfer(to, amount) => {
let fee = amount * 3 / 100;
amount - fee
},
Action::Refund(amount) => amount,
_ => 0
};
Wildcard Pattern
The _ pattern matches any value. It is typically used as the last arm to
handle all remaining cases:
match value {
0 => "zero",
1 => "one",
_ => "other"
}
In Route Actions
match can be used inside route actions for conditional dispatch:
routes {
execute(action: Action) => [
match action {
Action::Transfer(amount) => [
~> m_recipient with { value: amount }
],
Action::Pause => []
}
]
}
Exhaustiveness
The compiler checks that match expressions cover all possible variants when
matching on an enum. If any variant is missing and no wildcard _ arm is
present, the compiler will report an error. This guarantees that no case is
accidentally overlooked.
Blocks and Let Bindings
Blocks are sequences of statements enclosed in braces that evaluate to a value. They provide scoped computation with intermediate bindings, making complex expressions readable and composable.
Block Syntax
A block contains zero or more let bindings followed by a final expression.
The block evaluates to the value of the final expression:
{
let a = 10;
let b = 20;
a + b
}
This block evaluates to 30.
Let Bindings
The let keyword introduces a named value within a block. Each binding is
terminated by a semicolon:
{
let price = m_item_price;
let quantity = m_item_count;
let subtotal = price * quantity;
let tax = subtotal * 8 / 100;
subtotal + tax
}
Bindings are immutable – once assigned, a let variable cannot be
reassigned.
Type Annotations
Type annotations on let bindings are optional when the type can be inferred:
let count = 42; // inferred as integer
let name: String = "hello"; // explicit annotation
let bal: U256 = 0; // explicit when needed for type clarity
Blocks as Expressions
Because blocks are expressions, they can appear anywhere a value is expected:
In Let Bindings
let fee = {
let rate = if vip { 1 } else { 3 };
amount * rate / 100
};
In Member Transforms
Blocks are especially useful in member transforms for multi-step state computations:
m_balances: HashMap<address, U256> {
in transfer(to, amount) => {
let from = msg::sender;
let from_bal = m_balances[from];
let to_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(from, from_bal - amount)
.set(to, to_bal + amount)
}
}
In Pure Functions
Pure function bodies are blocks:
pure fn compute_fee(amount: U256, rate: u64) -> U256 {
let basis_points = rate as U256;
let fee = amount * basis_points / 10000;
fee
}
In If/Else Branches
Each branch of an if/else is a block:
let result = if complex_condition {
let x = compute_a();
let y = compute_b();
x + y
} else {
let fallback = get_default();
fallback * 2
};
Scope
Variables declared with let are visible only within their enclosing block.
They shadow any outer bindings with the same name:
let x = 10;
let result = {
let x = 20; // shadows the outer x
x + 5 // evaluates to 25
};
// x is still 10 here
Blocks in Route Actions
Inside route action lists, let bindings can be used to compute intermediate
values:
routes {
swap(amount: U256) => [
let fee = amount * 3 / 100;
let net = amount - fee;
Transfer(net) ~> m_recipient,
Transfer(fee) ~> m_treasury
]
}
The semicolon after let distinguishes bindings from actions, which are
separated by commas.
Method Calls and Field Access
Cambrian uses dot notation for both field access on records and method calls on built-in types. Bracket notation provides indexed access to maps and collections.
Field Access
Record fields are accessed with the dot operator:
let seller = listing.seller;
let is_live = listing.active;
let max = config.limits.max_deposit;
Member state variables are accessed by name within member transforms, where clauses, macros, and route actions:
where m_balance >= amount : throw 100
Map Access
HashMap values are accessed with bracket notation:
let bal = m_balances[account];
Bracket access panics if the key does not exist. For safe access, use the
.get() method which returns an Option<V>:
let bal = m_balances.get(account).unwrap_or(0);
Method Calls
Built-in types provide methods that are called with dot notation:
Vec Methods
let size = m_items.len();
let updated = m_items.push(new_item);
let first = m_items.get(0);
let empty = m_items.is_empty();
HashMap Methods
let value = m_map.get(key);
let updated = m_map.set(key, value);
let has_key = m_map.exists(key);
let without = m_map.remove(key);
let size = m_map.len();
Option Methods
let val = opt.unwrap();
let safe_val = opt.unwrap_or(default);
let present = opt.is_some();
let absent = opt.is_none();
String Methods
let size = name.len();
Method Chaining
Methods that return the collection type can be chained:
m_balances
.set(from, from_bal - amount)
.set(to, to_bal + amount)
This pattern is common in member transforms where multiple map updates happen atomically.
Type Casts
The as keyword casts a value to a different type:
let wide = narrow_val as u64;
let big = amount as U256;
Casts are necessary when performing arithmetic across different integer widths. The compiler requires explicit casts – implicit widening does not occur in most positions. See Arithmetic and Logic for how this interacts with checked and wrapping arithmetic.
Common Cast Patterns
// Narrowing (may truncate)
let byte_val = large_num as u8;
// Widening (always safe)
let big_val = small_num as u128;
// Between signed and unsigned
let signed = unsigned_val as i64;
Combining Access Patterns
Field access, method calls, and bracket access can be combined freely:
let bidder_balance = m_auctions[auction_id].highest_bid.amount;
let winner = m_results.get(round).unwrap_or(default_result).winner;
Pure Function Calls
Top-level pure functions are called by name (without dot notation):
let fee = compute_fee(amount, rate);
let clamped = clamp(value, min, max);
Macro Invocation
Entity-scoped macros are called with the @ prefix:
where @is_owner() : throw 100
Macros look like function calls but can access entity state. See Macros for details.
Entities
An entity is the core organizational unit in Cambrian: a named unit of
persistent state plus the routes that read and update it. Every Cambrian
program contains at least one entity. Backends differ in how an entity is
hosted (on-chain instance, Lean world model, in-process library), but the
.cam shape is the same.
Declaration
An entity is declared with the entity keyword, a name, and a brace-delimited
body (names are ordinary identifiers; PascalCase is conventional, not required):
entity Token {
// type aliases, records, enums, constants, macros
// routes { ... }
// member declarations (same level as routes)
}
What an Entity Contains
An entity body can include any of the following, in any order:
| 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 | Yes |
| Members | State variables with transforms | Yes |
A minimal entity needs at least one route and typically at least one member:
entity Counter {
routes {
increment() => []
}
m_count: u64 {
in increment() => m_count + 1
}
}
What an entity is at runtime
Across targets, an entity is an independent unit that:
- Owns its own persistent state (member declarations in the entity body).
- Exposes behaviour through its
routes. - Can send messages to other entities with
~>.
On EVM it is typically deployed at an address; on Lean it appears as state and route transitions in a world model.
Entity-Level Definitions
Type Aliases
entity Token {
type Amount = U256;
type AccountId = address;
}
Records
entity Marketplace {
record Listing {
seller: address,
price: U256,
active: bool
}
}
Enums
entity Governance {
enum ProposalStatus {
Pending,
Approved,
Rejected
}
}
Constants
entity Vault {
const MIN_DEPOSIT: U256 = 1_000_000;
const MAX_SIGNERS: u64 = 10;
}
Macros
entity Wallet {
macro is_owner() -> bool = {
msg::sender == m_owner
}
macro require_funded(amount: U256) -> bool = {
m_balance >= amount
}
}
Multiple Entities
A .cam file can define multiple entities. This is the usual approach when
several stateful units communicate:
entity Broker {
routes {
place_order(item_id: u64, quantity: u64) => [
Shop::fulfill(item_id, quantity) ~> m_shop
]
}
m_shop: address {
in init(shop) => shop
}
}
entity Shop {
routes {
fulfill(item_id: u64, quantity: u64) => [
Ledger::record_sale(item_id, quantity, msg::sender) ~> m_ledger
]
}
m_ledger: address {
in init(_, ledger) => ledger
}
}
entity Ledger {
routes {
record_sale(item_id: u64, quantity: u64, buyer: address) => []
}
m_sales: Vec<(u64, u64, address)> {
in record_sale(item_id, quantity, buyer) =>
m_sales.push((item_id, quantity, buyer))
}
}
When entities are defined in the same file, the compiler can verify that messages sent between them match the declared routes.
Entity vs. Pure Functions
Code that does not need state access should be placed in pure functions outside any entity. Pure functions are available to all entities in the same file:
pure fn compute_fee(amount: U256, bps: u64) -> U256 {
amount * (bps as U256) / 10000
}
entity Token {
routes {
transfer(to: address, amount: U256) => [
let fee = compute_fee(amount, 30);
// ...
]
}
// ...
}
See Pure Functions for details.
Routes
Routes are the named entry points of an entity — the primary interface
callers use to interact with it. Invoking a route runs its preconditions,
member transforms, and action list. How a host dispatches to a route (EVM
call, test harness, …) is target-specific; the .cam
surface stays the same.
Routes block
entity Wallet {
routes {
init setup(owner: address) => []
deposit()
where msg::value > 0 : throw ZeroDeposit()
=> []
withdraw(amount: U256)
where msg::sender == m_owner : throw Unauthorized()
&& m_balance >= amount : throw InsufficientBalance(m_balance, amount)
=> [
~> msg::sender with { value: amount }
]
view get_balance() -> U256 => [
return(m_balance)
]
pure compute_fee(amount: U256, rate: u64) -> U256 => [
return(amount * (rate as U256) / 10000)
]
accept receive() => []
}
}
Route kinds
| 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 but not an external entry
point. Other routes invoke it with the call action:
entity Service {
routes {
run() => [
call finalize_step()
]
private finalize_step() => []
}
m_done: bool {
in finalize_step() => true
}
}
See Call (Private Routes) for restrictions and per-target lowering.
Anatomy
[modifier] name(parameters) [from clause] [where clause] [-> ReturnType] => [actions]
| 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 |
| Where clause | No | Preconditions — Where Clause |
| Return type | No | -> T when returning a value (V42 if return(expr) lacks it) |
| 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:
transfer(to: address, amount: U256) => [
let fee = compute_fee(amount);
Transfer(amount - fee) ~> to,
Transfer(fee) ~> m_treasury
]
See Route Actions.
Members
Members name the routes that update them:
m_balance: U256 {
in deposit(_) => m_balance + msg::value
in withdraw(amount) => m_balance - amount
}
Empty action lists
routes {
increment() => []
}
m_count: u64 {
in increment() => m_count + 1
}
The [] is still required syntactically.
Regular Routes
Regular routes are the default route kind. They can read and modify entity state, send messages, deploy entities, and run other route actions. Most routes in a typical Cambrian entity are regular routes.
Syntax
A regular route has no keyword modifier:
routes {
deposit(amount: U256) => [
// actions
]
}
Parameters
Route parameters are typed and positional:
transfer(to: address, amount: U256) => [
// to and amount are available here
]
Parameters are available in the action list and are referenced by name in member transforms. A route with no parameters uses empty parentheses:
increment() => []
State Access
Regular routes have full access to entity state through members. State changes are expressed in member transforms, not in the route body itself:
entity Counter {
routes {
add(value: u64) => []
}
m_total: u64 {
in add(value) => m_total + value
}
}
The route body contains actions (sends, deploys, conditionals); state mutation is handled by the member system.
Actions
The action list can contain any combination of sends, deploys, conditionals,
let bindings, emits, and evm:: intrinsics:
routes {
purchase(item_id: u64)
where msg::value >= m_prices[item_id] : throw 100
=> [
let price = m_prices[item_id];
let change = msg::value - price;
if change > 0 => [
~> msg::sender with { value: change }
],
Sold(item_id, msg::sender) ~> m_ledger
]
}
With Preconditions
Regular routes commonly use where clauses to enforce invariants:
withdraw(amount: U256)
where msg::sender == m_owner : throw 101
&& m_balance >= amount : throw 102
=> [
~> msg::sender with { value: amount }
]
If any where condition is false, the transaction reverts with the specified
error code, and no state changes or actions take effect.
With Sender Verification
The from clause restricts which entity or caller may invoke the route:
credit(account: address, amount: U256)
from Shop(_)
=> []
See From Clause for details.
Complete Example
A token transfer route combining parameters, preconditions, actions, and member transforms:
entity Token {
routes {
transfer(to: address, amount: U256)
where m_balances[msg::sender] >= amount : throw 100
&& to != msg::sender : throw 101
=> []
}
m_balances: HashMap<address, U256> {
in transfer(to, amount) => {
let from = msg::sender;
let from_bal = m_balances[from];
let to_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(from, from_bal - amount)
.set(to, to_bal + amount)
}
}
}
The route body is [] (empty) because the state change is entirely expressed
in the member transform. This is a common and idiomatic pattern.
View Routes
View routes are read-only entry points. They can inspect entity state but cannot modify it, send messages, or perform side effects. Use them to expose queries to callers and other entities.
Syntax
View routes are declared with the view keyword and must specify a return type:
view get_balance() -> U256 => [
return(m_balance)
]
Return Type
Every view route must declare a return type with -> Type and include a
return(value) action:
view get_owner() -> address => [
return(m_owner)
]
view get_name() -> String => [
return(m_name)
]
view total_supply() -> U256 => [
return(m_total_supply)
]
Read-Only Guarantee
View routes have the following restrictions:
| Allowed | Forbidden |
|---|---|
| Read member values | Modify members (no transforms) |
| Call pure functions | Send messages (~>) |
Use if/else, match | Deploy entities |
| Compute intermediate values | Side effects (~>, deploy, emit, evm::) |
These constraints are enforced by the compiler. A view route that attempts to trigger a member transform or send a message will produce a compilation error.
Computed Views
View routes can perform computations on state before returning:
view get_balance_of(account: address) -> U256 => [
return(m_balances.get(account).unwrap_or(0))
]
view is_approved(owner: address, spender: address) -> bool => [
let allowance = m_allowances.get(owner)
.unwrap_or({})
.get(spender)
.unwrap_or(0);
return(allowance > 0)
]
Conditional Returns
Views can use if/else and match to determine the return value:
view get_status_label() -> String => [
return(match m_status {
Status::Active => "active",
Status::Paused => "paused",
Status::Closed => "closed",
_ => "unknown"
})
]
Views with Parameters
View routes can accept parameters to query specific data:
view get_listing(id: u64) -> Listing => [
return(m_listings[id])
]
view get_vote_count(proposal_id: u64) -> u64 => [
return(m_votes.get(proposal_id).unwrap_or(0))
]
No Member Transforms
View routes do not appear in member transform blocks. Since views cannot
modify state, no member declares in get_balance(_) => .... If you find
yourself writing a member transform for a view route, you need a regular route
instead.
Complete Example
An entity with multiple view routes:
entity Token {
routes {
// ... regular routes ...
view name() -> String => [
return(m_name)
]
view symbol() -> String => [
return(m_symbol)
]
view total_supply() -> U256 => [
return(m_total_supply)
]
view balance_of(account: address) -> U256 => [
return(m_balances.get(account).unwrap_or(0))
]
view allowance(owner: address, spender: address) -> U256 => [
return(
m_allowances.get(owner)
.unwrap_or({})
.get(spender)
.unwrap_or(0)
)
]
}
}
Pure Routes
Pure routes are stateless computations exposed as entry points. They have no access to entity state and cannot perform side effects. Use them when callers need a pure calculation via the same route interface as other operations.
Syntax
Pure routes are declared with the pure keyword and must specify a return
type:
pure compute_fee(amount: U256, rate: u64) -> U256 => [
return(amount * (rate as U256) / 10000)
]
Restrictions
Pure routes have the strictest constraints of any route kind:
| 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 => [
return(if a >= b { a } else { b })
]
pure min(a: u64, b: u64) -> u64 => [
return(if a <= b { a } else { b })
]
pure clamp(val: u64, lo: u64, hi: u64) -> u64 => [
return(if val < lo { lo } else if val > hi { hi } else { val })
]
Pure Routes vs. Pure Functions
Cambrian has two forms of pure computation:
| 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 {
pure convert(amount: U256, rate: U256, decimals: u64) -> U256 => [
let factor = 10 as U256;
return(amount * rate / factor)
]
pure percentage(value: U256, bps: u64) -> U256 => [
return(value * (bps as U256) / 10000)
]
}
}
No Member Transforms
Like view routes, pure routes do not appear in member transform blocks. Since
they cannot access state, no member can declare a transform in a pure route.
Init Routes
Init routes run once when an entity instance is created. They supply the arguments that member transforms use for initial state. An entity may have at most one init route.
Syntax
Init routes are declared with the init keyword:
init setup(owner: address, name: String) => [
// initialization actions (optional)
]
Purpose
The init route is invoked exactly once at instance creation. Its primary role is to provide initial values for members through their transforms:
entity Token {
routes {
init deploy(
name: String,
symbol: String,
decimals: u8,
owner: address
) => []
}
m_name: String {
in deploy(name, _, _, _) => name
}
m_symbol: String {
in deploy(_, symbol, _, _) => symbol
}
m_decimals: u8 {
in deploy(_, _, decimals, _) => decimals
}
m_owner: address {
in deploy(_, _, _, owner) => owner
}
m_total_supply: U256 {
in deploy(_, _, _, _) => 0
}
}
Parameter Passing
Init route parameters are passed at deployment time. The deploying entity (or off-chain client) provides these values as part of the deployment transaction:
// In another entity, deploying a Token (identity + init args):
deploy Token("MyToken", "MTK", 18) with { value: init_value }
Init Actions
While the init route’s action list is often empty ([]), it can contain
actions that should execute at deployment:
init setup(owner: address, treasury: address) => [
Welcome(owner) ~> treasury
]
Member Initialization
Every member that needs an initial value must include a transform referencing the init route. Members without an init transform start at their type’s default value (typically zero or empty).
The parameter pattern in a member transform for an init route uses positional
matching with _ for unused parameters:
m_owner: address {
in deploy(_, _, _, owner) => owner
}
m_paused: bool {
in deploy(_, _, _, _) => false
}
Complete Example
A vault entity with an init route:
entity Vault {
const MIN_DEPOSIT: U256 = 100;
routes {
init create(
owner: address,
guardian: address,
daily_limit: U256
) => []
deposit()
where msg::value >= MIN_DEPOSIT : throw 100
=> []
withdraw(amount: U256)
where msg::sender == m_owner : throw 101
&& m_balance >= amount : throw 102
=> [
~> msg::sender with { value: amount }
]
}
m_owner: address {
in create(owner, _, _) => owner
}
m_guardian: address {
in create(_, guardian, _) => guardian
}
m_daily_limit: U256 {
in create(_, _, daily_limit) => daily_limit
}
m_balance: U256 {
in create(_, _, _) => 0
in deposit() => m_balance + msg::value
in withdraw(amount) => m_balance - amount
}
}
Constraints
- An entity can have at most one init route.
- Init routes cannot be called after deployment — they execute exactly once.
- Init routes can include
whereclauses to validate constructor arguments. - On EVM with deterministic addresses, construction is split into a factory-guarded
initialize(); see Deterministic Addresses.
From Clause
The from clause restricts who may invoke a route by checking
msg::sender against an expected entity (or address) identity.
On EVM this lowers to a require(msg.sender == …) style guard.
Syntax
credit(account: address, amount: U256)
from Shop(m_shop_id) : throw Unauthorized()
=> []
The optional : throw ErrorName(...) supplies a named custom error when the
sender check fails (see Custom Errors).
Multiple senders
Join alternatives with | (OR):
update_price(asset: String, price: U256)
from Oracle(m_oracle_id) | Admin(m_admin_id) : throw Unauthorized()
=> []
From with where
from runs before where:
execute_trade(amount: U256)
from Broker(m_broker_id) : throw Unauthorized()
where amount > 0 : throw ZeroAmount()
&& m_status == Status::Active : throw Paused()
=> []
EVM arity rules (V33)
How many arguments from Entity(...) takes depends on addressing mode:
| Mode | Arity |
|---|---|
| Non-deterministic (default) | Only from Entity(addr) — a single address argument |
deterministic_addresses: true | Arity must match the target entity’s identity member count (zero for singletons: from Registry()) |
extern entity targets skip V33. Plain address members used as senders
can also be compared with where msg::sender == m_owner when you do not need
entity-typed address prediction.
In deterministic mode, from Entity(args) compares msg.sender to the same
CREATE2 expression as Entity.address(args), so authentication and addressing
stay in sync. See Deterministic Addresses.
When to use from
| 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 in deterministic mode (EVM) |
| Plain address stored in a member | 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:
where msg::value > 0 : throw ZeroValue()
where msg::sender == m_owner : throw Unauthorized()
where @is_owner() : throw Unauthorized()
where m_whitelist.exists(msg::sender) : throw NotWhitelisted()
where (m_deadline == 0 || sys::now < m_deadline) : throw Expired()
Where with from
Sender checks run before where:
process(data: bytes)
from Oracle(m_oracle_id) : throw BadSender()
where m_status == Status::Active : throw Paused()
&& data.len() > 0 : throw EmptyData()
=> []
Per-phase where
On phased routes, a later phase may attach its
own guard that can read var captures from earlier phases:
withdraw(who: address) => [
fetch: [
var bal = balanceOf(who) ~> m_token;
]
act where bal > 0 : throw EmptyBalance(): [
// …
]
]
Route-level where runs before any phase and must not reference var
bindings (V27). Per-phase where may only see vars from strictly
earlier phases (V28). Prefer unphased routes unless you need a
synchronous capture or interleaved effects.
Init routes
init setup(owner: address, limit: U256)
where limit > 0 : throw BadLimit()
=> []
Receive and Fallback
On the EVM target, the route names receive and fallback are reserved and
lower to Solidity’s special functions of the same name.
Shape rules
Both routes must:
- take no parameters;
- declare no return type;
- not be marked
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. Multiple actions are separated by commas:
transfer(to: address, amount: U256) => [
let fee = compute_fee(amount);
let net = amount - fee;
Transfer(net) ~> to,
Transfer(fee) ~> m_treasury
]
An empty action list is written as []:
increment() => []
Action Types
| Action | Syntax | Chapter |
|---|---|---|
| Send message | Message(args) ~> dest | Send Messages |
| Plain transfer | ~> dest with { value: amount } | Send Messages |
| Deploy | deploy Entity with { ... } | 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:
swap(amount_in: U256) => [
let fee = amount_in * 3 / 1000;
let amount_out = calculate_output(amount_in - fee);
Transfer(amount_out) ~> msg::sender,
CollectFee(fee) ~> m_fee_collector
]
Let bindings are evaluated in order. Later bindings and actions can reference earlier bindings.
Execution Order
Actions within a route execute in declaration order:
- All
letbindings are evaluated. - Sends and deploys are queued.
- Conditional actions branch based on their guard.
- Member transforms execute atomically with the route.
The combination of actions and member transforms defines the complete effect of a route invocation.
Actions and State
Actions have read access to member values (the pre-transform values). State mutations happen through member transforms, not through actions. This separation is a core design principle of Cambrian.
entity Counter {
routes {
increment_and_notify(observer: address) => [
// m_count here is the *pre-increment* value
Notify(m_count) ~> observer
]
}
m_count: u64 {
in increment_and_notify(_) => m_count + 1
}
}
To access the post-transform value within the same route, use temporal references. See Temporal References.
Complete Example
entity DEX {
routes {
swap(token_in: address, amount_in: U256, min_out: U256)
where amount_in > 0 : throw 100
=> [
let reserve_in = m_reserves[token_in];
let reserve_out = m_reserves[m_paired_token[token_in]];
let amount_out = (amount_in * reserve_out) / (reserve_in + amount_in);
if amount_out < min_out => [
throw SlippageExceeded()
] else [
Token::transfer(msg::sender, amount_out) ~> m_paired_token[token_in]
]
]
}
}
Send Messages (~>)
The ~> action is how Cambrian expresses outbound calls and value transfers.
On EVM, typed sends lower to external calls; plain transfers lower to native
currency sends.
Named (typed) send
Invoke a route on a destination whose type is known:
transfer(amount) ~> m_token
Token::transfer(to, amount) ~> m_token_address
The destination should be an Address<Entity> (or a predicted
Entity.address(...)). Named sends to a plain address are rejected
(V23). The target entity must be in the project or declared
extern entity (E22 otherwise).
Capturing a return value
var bal = balanceOf(who) ~> m_token;
This is a synchronous call: the callee runs, and bal binds the returned
value for later actions, transforms, or a per-phase where. Captures are the
usual reason to introduce phases; unphased
routes already flush storage updates before external calls
(checks-effects-interactions).
Plain value transfer
~> recipient with { value: amount }
Transfers native currency without calling a function on the destination.
Message options (with)
On EVM the meaningful option is value — wei attached to the CALL / CREATE:
Worker::start() ~> worker with { value: amount }
~> msg::sender with { value: amount }
Destinations
Notify(data) ~> m_observer
~> to with { value: amount }
~> msg::sender with { value: amount }
Ping() ~> Token.address(token_id)
Multiple sends
distribute(amount: U256) => [
let share = amount / 3;
~> m_partner_a with { value: share },
~> m_partner_b with { value: share },
~> m_partner_c with { value: amount - share * 2 }
]
Example
entity Token {
routes {
transfer(to: address, amount: U256)
where m_balances[msg::sender] >= amount : throw InsufficientBalance(m_balances[msg::sender], amount)
=> [
TransferNotification(msg::sender, to, amount) ~> m_observer
]
}
m_balances: HashMap<address, U256> {
in transfer(to, amount) => {
let from = msg::sender;
m_balances
.set(from, m_balances[from] - amount)
.set(to, m_balances.get(to).unwrap_or(0) + amount)
}
}
m_observer: Address<Observer> {
in init(_, observer) => observer
}
}
Deploy
The deploy action creates a new entity instance from inside a route.
Basic form
Pass identity / init arguments directly:
deploy UniswapV2Pair(token0, token1)
Optional with { value: … } attaches native currency to the new instance
(meaningful on EVM; ignored or unavailable on hosts without a native asset):
deploy Vault(vault_id) with { value: msg::value }
Argument arity must match the target’s constructor surface (identity members plus init-route parameters) — V32.
Deterministic addresses (EVM)
With deterministic_addresses: true in project.yaml, the
EVM backend:
- 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 {
routes {
create_escrow(buyer: address, seller: address, amount: U256)
where msg::value >= amount : throw Underfunded()
=> [
deploy Escrow(buyer, seller, amount) with { value: msg::value }
]
}
}
entity Escrow {
identity m_buyer: address
identity m_seller: address
routes {
constructor(amount: U256) => []
release()
from Buyer(m_buyer) : throw Unauthorized()
=> [
~> m_seller with { value: m_amount }
]
}
m_amount: U256 {
in constructor(amount) => amount
}
}
(Exact identity / constructor split depends on your entity design; the
factory always keeps CREATE2 occupancy aligned with Entity.address.)
Conditional Actions
Conditional actions allow a route to execute different sets of actions based on
runtime conditions. They use an if/else syntax within the action list.
Syntax
if condition => [
// actions when true
] else [
// actions when false
]
The else branch is optional:
if condition => [
// actions when true
]
Basic Usage
Execute an action only when a condition is met:
routes {
withdraw(amount: U256) => [
if amount > 0 => [
~> msg::sender with { value: amount }
]
]
}
If/Else
Choose between two action sequences:
routes {
process(amount: U256) => [
if amount >= m_threshold => [
LargeTransfer(amount) ~> m_compliance,
~> msg::sender with { value: amount }
] else [
~> msg::sender with { value: amount }
]
]
}
Conditions
The condition can be any boolean expression:
Comparisons
if m_balance >= amount => [
~> recipient with { value: amount }
]
Macro Calls
if @is_owner() => [
~> m_treasury with { value: m_balance }
]
Member State
if m_status == Status::Active => [
Process(data) ~> m_processor
] else [
Queue(data) ~> m_queue
]
Compound Conditions
if amount > 0 && m_balance >= amount => [
~> msg::sender with { value: amount }
]
Nested Conditionals
Conditional actions can be nested for multi-branch logic:
routes {
categorize(score: u64) => [
if score >= 90 => [
Award("gold") ~> m_rewards
] else [
if score >= 70 => [
Award("silver") ~> m_rewards
] else [
Award("bronze") ~> m_rewards
]
]
]
}
Conditional Sends
A common pattern is conditionally sending a refund:
routes {
bid()
where msg::value > m_highest_bid : throw 100
=> [
if m_highest_bid > 0 => [
~> m_highest_bidder with { value: m_highest_bid }
]
]
}
This refunds the previous highest bidder only if there was a previous bid.
Conditional Deploy
Deploy an entity only under certain conditions:
routes {
ensure_vault(user: address) => [
if m_vaults.exists(user) == false => [
deploy UserVault(user) with { value: 1_000_000 }
]
]
}
Conditionals with Let Bindings
Let bindings can precede conditional actions:
routes {
distribute(total: U256) => [
let fee = total * 3 / 100;
let net = total - fee;
if fee > 0 => [
~> m_treasury with { value: fee }
],
~> m_recipient with { value: net }
]
}
Complete Example
entity Escrow {
routes {
resolve(approved: bool)
where msg::sender == m_arbiter : throw 200
=> [
if approved => [
~> m_seller with { value: m_amount },
Resolved(m_deal_id, true) ~> m_logger
] else [
~> m_buyer with { value: m_amount },
Resolved(m_deal_id, false) ~> m_logger
]
]
}
}
Return Values
The return action produces a value from a route. It is required in view and
pure routes and provides the mechanism for returning computation results to
callers.
Syntax
return(expression)
The parentheses are required. The expression inside is evaluated and returned to the caller.
In View Routes
Every view route must include a return action:
view get_balance() -> U256 => [
return(m_balance)
]
view get_owner() -> address => [
return(m_owner)
]
In Pure Routes
Every pure route must include a return action:
pure add(a: u64, b: u64) -> u64 => [
return(a + b)
]
pure compute_fee(amount: U256, bps: u64) -> U256 => [
return(amount * (bps as U256) / 10000)
]
Computed Returns
The return expression can be any expression, including blocks, conditionals, and function calls:
view get_status_label() -> String => [
return(match m_status {
Status::Active => "active",
Status::Paused => "paused",
_ => "unknown"
})
]
view get_effective_balance(account: address) -> U256 => [
return({
let raw = m_balances.get(account).unwrap_or(0);
let locked = m_locked.get(account).unwrap_or(0);
raw - locked
})
]
Return with Let Bindings
Let bindings can compute intermediate values before the return:
view get_share(account: address) -> U256 => [
let balance = m_balances.get(account).unwrap_or(0);
let total = m_total_supply;
return(if total > 0 {
balance * 10000 / total
} else {
0
})
]
Return Type Agreement
The type of the return expression must match the declared return type of the route. The compiler enforces this:
// Correct: returns U256 as declared
view total() -> U256 => [
return(m_supply)
]
// Error: return type mismatch
// view total() -> U256 => [
// return("not a number")
// ]
Return in Regular Routes
Regular routes typically do not use return – their purpose is to cause
side effects (state changes, message sends) rather than produce values. If a
regular route does not need to return a value, the action list contains only
sends, deploys, conditionals, and let bindings.
Complete Example
An entity exposing several computed views:
entity Pool {
routes {
view total_liquidity() -> U256 => [
return(m_reserve_a + m_reserve_b)
]
view price(token: address) -> U256 => [
return(if token == m_token_a {
m_reserve_b * 1_000_000 / m_reserve_a
} else {
m_reserve_a * 1_000_000 / m_reserve_b
})
]
view share_of(provider: address) -> U256 => [
let lp = m_lp_balances.get(provider).unwrap_or(0);
let total_lp = m_total_lp;
return(if total_lp > 0 {
lp * (m_reserve_a + m_reserve_b) / total_lp
} else {
0
})
]
pure estimate_output(amount_in: U256, reserve_in: U256, reserve_out: U256) -> U256 => [
return(amount_in * reserve_out / (reserve_in + amount_in))
]
}
}
Call (Private Routes)
The call action invokes another route on the same entity in-process.
It is the usual way to run a private route — a route that external
callers cannot invoke directly.
Syntax
call routeName(args)
Arguments must match the target route’s parameter list. There is no ~> or
return capture: call runs the private route for side effects (member
transforms and its action list) and continues with the caller route afterward.
Private routes
Declare a route with the private modifier:
entity Ledger {
routes {
publish(total: U256) => [
call apply_total(total)
]
private apply_total(total: U256) => []
}
m_total: U256 {
in apply_total(total) => total
}
}
| Target | Lowering (conceptual) |
|---|---|
| EVM | private Solidity function; not in the public ABI |
| Lean | Internal transition helper in generated modules |
Private routes still participate in member transforms (in apply_total(...)).
Restrictions
| Allowed in caller | Forbidden |
|---|---|
Regular, init, phased routes | view / pure routes calling private (V11) |
call after sends, deploys, let | call with var capture (use ~> for return values) |
pure routes cannot use call to private routes or any impure construct.
When to use
Use private route + call to split one public entry point into internal
steps without exposing extra ABI surface — shared checks, multi-step setup, or
helper routes that only make sense inside the entity.
For return values from another entity, use a typed send with var capture
(see Send Messages). For events, use Emit Events.
Emit Events
emit Name(args) is a route-body action that writes a declared event to the
transaction log.
transfer(dst: address, amount: U256) => [
emit Transfer(msg::sender, dst, amount)
]
Declare the event first (event Name(...) at program or entity scope), then
emit it from any non-pure route. Argument count and types must match the
declaration (V35); an undeclared name is V34.
Full declaration rules, indexed parameters, and V36 (max three indexed
fields on EVM) are in Events.
Members
Members are persistent state variables owned by an entity. Unlike traditional programming where state is mutated imperatively, Cambrian members declare how they change in response to routes. Each member defines its own transformation logic – this is the member-centric state model.
Declaring Members
Members are declared directly in the entity body, at the same level as
routes { } — there is no separate members { } wrapper in the grammar:
m_owner: address {
in setup(owner) => owner
}
m_balance: U256 {
in deposit(amount) => m_balance + amount
in withdraw(amount) => m_balance - amount
}
m_count: u64 {
in increment() => m_count + 1
in reset() => 0
}
Member Declaration
Each member has a name, a type, and one or more transforms:
m_name: Type {
in route_name(params) => new_value_expression
in another_route(params) => another_expression
}
| 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
When read in a route’s action list or where clause, the value is the pre-transform value (the value before the current route’s transforms are applied).
Transform Basics
Transforms define the new value of a member after a route executes. The transform body is an expression:
m_count: u64 {
in increment() => m_count + 1
}
This says: “When the increment route fires, the new value of m_count is
the current value plus one.”
See State Transforms for detailed coverage.
Members Without Transforms
If a member does not list a transform for a route, it remains unchanged when
that route fires. Only members that explicitly declare in route_name(...) are
affected by that route.
Temporal References
Within a single route execution, you may need to reference the post-transform
value of a member. The ^ prefix provides this:
m_count: u64 {
in increment() => m_count + 1
}
m_count_doubled: u64 {
in increment() => ^m_count * 2
}
Here ^m_count refers to the value of m_count after its transform in the
increment route (i.e., m_count + 1).
See Temporal References for details.
Member Types
Members can be of any type:
| 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 {
init deploy(name: String, symbol: String, owner: address) => []
mint(to: address, amount: U256)
where msg::sender == m_owner : throw 100
=> []
transfer(to: address, amount: U256)
where m_balances[msg::sender] >= amount : throw 101
=> []
}
m_name: String {
in deploy(name, _, _) => name
}
m_symbol: String {
in deploy(_, symbol, _) => symbol
}
m_owner: address {
in deploy(_, _, owner) => owner
}
m_total_supply: U256 {
in deploy(_, _, _) => 0
in mint(_, amount) => m_total_supply + amount
}
m_balances: HashMap<address, U256> {
in mint(to, amount) => {
let current = m_balances.get(to).unwrap_or(0);
m_balances.set(to, current + amount)
}
in transfer(to, amount) => {
let from = msg::sender;
let from_bal = m_balances[from];
let to_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(from, from_bal - amount)
.set(to, to_bal + amount)
}
}
}
State Transforms
State transforms define how member values change when routes fire. Each
transform is an in clause inside a member declaration that specifies the new
value as a function of the current state and route parameters.
Syntax
m_name: Type {
in route_name(param1, param2) => new_value_expression
}
The expression after => is evaluated to produce the member’s new value. The
current value of the member is accessible by its name (m_name) within the
expression.
Simple Transforms
The most common transforms compute the new value from the old value and route parameters:
m_count: u64 {
in increment() => m_count + 1
in decrement() => m_count - 1
in reset() => 0
}
m_balance: U256 {
in deposit(amount) => m_balance + amount
in withdraw(amount) => m_balance - amount
}
Parameter Matching
Transform parameters must match the route parameters by position. The names can differ, but the count and order must correspond:
routes {
transfer(to: address, amount: U256) => []
}
m_balances: HashMap<address, U256> {
// (to, amount) matches (to: address, amount: U256)
in transfer(to, amount) => {
let from = msg::sender;
m_balances
.set(from, m_balances[from] - amount)
.set(to, m_balances.get(to).unwrap_or(0) + amount)
}
}
Ignoring Parameters
Use _ to ignore route parameters that the member does not need:
m_owner: address {
in deploy(_, _, owner) => owner // only needs the third parameter
}
m_total_supply: U256 {
in mint(_, amount) => m_total_supply + amount // ignores 'to'
}
Block Transforms
When a transform requires multiple steps, use a block expression:
m_balances: HashMap<address, U256> {
in transfer(to, amount) => {
let from = msg::sender;
let from_bal = m_balances[from];
let to_bal = m_balances.get(to).unwrap_or(0);
m_balances
.set(from, from_bal - amount)
.set(to, to_bal + amount)
}
}
Conditional Transforms
Transforms can use if/else and match:
m_high_score: u64 {
in submit(score) => if score > m_high_score {
score
} else {
m_high_score
}
}
m_status: Status {
in toggle() => match m_status {
Status::Active => Status::Paused,
Status::Paused => Status::Active,
_ => m_status
}
}
Multiple Routes
A member can have transforms for multiple routes. Each in clause specifies
how the member changes for one route:
m_balance: U256 {
in deploy(_, _, _) => 0
in deposit(amount) => m_balance + amount
in withdraw(amount) => m_balance - amount
in transfer_out(_, amount) => m_balance - amount
in receive(amount) => m_balance + amount
}
If a route is not listed, the member’s value is unchanged when that route fires.
Accessing Other Members
A transform expression can read other members’ values:
m_total_supply: U256 {
in mint(_, amount) => m_total_supply + amount
}
m_max_reached: bool {
in mint(_, amount) => (m_total_supply + amount) >= m_cap
}
When referencing other members in a transform, you get their pre-transform
values (the values before the current route). To access post-transform values,
use temporal references (^m_name). See
Temporal References.
Accessing msg:: and sys:: Context
Transforms can access message and system context:
m_last_sender: address {
in deposit(_) => msg::sender
in withdraw(_) => msg::sender
}
m_last_update: u64 {
in deposit(_) => sys::now
in withdraw(_) => sys::now
}
Record and Collection Transforms
Record Functional Update
m_config: Config {
in update_fee(new_fee) => m_config { fee_rate: new_fee }
in update_limit(new_limit) => m_config { max_amount: new_limit }
}
HashMap Updates
m_votes: HashMap<address, bool> {
in vote() => m_votes.set(msg::sender, true)
}
Vec Updates
m_history: Vec<u64> {
in record(value) => m_history.push(value)
}
Transform Atomicity
All member transforms for a given route execute atomically. Either all transforms complete successfully, or the entire transaction reverts. There is no partial state update.
m_balance_a: U256 {
in swap(amount) => m_balance_a - amount
}
m_balance_b: U256 {
in swap(amount) => m_balance_b + amount
}
Both m_balance_a and m_balance_b update together. If the subtraction in
m_balance_a would underflow (with checked arithmetic), neither member is
updated.
Temporal References (^x)
Temporal references allow a member transform to access the post-transform
value of another member within the same route execution. The ^ prefix
distinguishes the “after” value from the default “before” value.
The Problem
Consider a counter and a flag that should be set when the counter exceeds a threshold:
m_count: u64 {
in increment() => m_count + 1
}
m_over_limit: bool {
in increment() => m_count > 100 // BUG: uses pre-transform value
}
Here m_count in the m_over_limit transform refers to the value before
the increment. If m_count is 100, the increment makes it 101, but
m_over_limit sees 100 and evaluates to false. The flag is always one step
behind.
The Solution: ^m_name
The ^ prefix accesses the member’s value after its transform has been
applied:
m_count: u64 {
in increment() => m_count + 1
}
m_over_limit: bool {
in increment() => ^m_count > 100 // CORRECT: uses post-transform value
}
Now ^m_count is m_count + 1 (the result of m_count’s own transform in
the increment route). When m_count is 100, ^m_count is 101, and
m_over_limit correctly becomes true.
Syntax
^m_member_name
The ^ prefix can be applied to any member that has a transform in the same
route. It evaluates to the value that member will have after the route
completes.
Common Patterns
Derived State
Compute a member value based on another member’s updated value:
m_balance: U256 {
in deposit(amount) => m_balance + amount
}
m_is_funded: bool {
in deposit(_) => ^m_balance > 0
}
Maintaining Invariants
Keep a count consistent with a collection:
m_items: Vec<u64> {
in add_item(item) => m_items.push(item)
}
m_item_count: u64 {
in add_item(_) => ^m_items.len()
}
Chained Dependencies
Temporal references can chain through multiple members:
m_price: U256 {
in update_price(new_price) => new_price
}
m_fee: U256 {
in update_price(_) => ^m_price * 3 / 100
}
m_net_price: U256 {
in update_price(_) => ^m_price - ^m_fee
}
Each ^ reference resolves to the post-transform value of the referenced
member. The compiler determines the correct evaluation order.
Without ^ (Pre-Transform Values)
Without the ^ prefix, member references always resolve to the pre-transform
(current) value:
| 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 where rewards are proportional to the updated stake:
entity Staking {
routes {
stake(amount: U256)
where msg::value >= amount : throw 100
=> []
claim() => [
~> msg::sender with { value: m_pending_rewards[msg::sender] }
]
}
m_stakes: HashMap<address, U256> {
in stake(amount) => {
let current = m_stakes.get(msg::sender).unwrap_or(0);
m_stakes.set(msg::sender, current + amount)
}
}
m_total_staked: U256 {
in stake(amount) => m_total_staked + amount
}
m_reward_rate: U256 {
in stake(_) => if ^m_total_staked > 0 {
m_reward_pool / ^m_total_staked
} else {
0
}
}
m_pending_rewards: HashMap<address, U256> {
in claim() => m_pending_rewards.set(msg::sender, 0)
}
}
Here ^m_total_staked in the m_reward_rate transform uses the updated total
(after adding the new stake), ensuring the reward rate reflects the new state.
Rules
^m_xcan only be used in a member transform for a route wherem_xalso has a transform. Ifm_xhas no transform in router, then^m_xin routeris the same asm_x.- Circular temporal dependencies (where
^m_adepends on^m_bwhich depends on^m_a) are detected and rejected by the compiler. - Temporal references are resolved statically at compile time – there is no runtime overhead.
Events
Events declare structured log topics that a route can write with emit.
On EVM they mirror the ABI event model and lower to Solidity event
declarations plus emit statements.
Declaration
An event may appear at program scope (file top level) or inside an
entity (alongside routes / members). Both forms are visible for emit from
that entity; program-scope events are shared across entities in the file.
event Transfer(indexed src: address, indexed dst: address, amount: U256);
entity Token {
event Approval(indexed owner: address, indexed spender: address, amount: U256);
routes {
transfer(dst: address, amount: U256) => [
emit Transfer(msg::sender, dst, amount)
]
approve(spender: address, amount: U256) => [
emit Approval(msg::sender, spender, amount)
]
}
}
Indexed parameters
Prefix a parameter with indexed to place it in a log topic (searchable /
filterable off-chain):
event Transfer(indexed src: address, indexed dst: address, amount: U256);
On the EVM target, a non-anonymous event may have at most three indexed
parameters (V36). Extra indexed fields are rejected at validation time.
Emitting
Use the emit action inside a route body:
emit Transfer(msg::sender, dst, amount)
Rules:
| 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 |
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 {
withdraw(amount: U256)
from Owner(m_owner) : throw Unauthorized()
where m_balance >= amount : throw InsufficientBalance(m_balance, amount)
=> [
~> msg::sender with { value: amount }
]
kill()
from Owner(m_owner) : throw Unauthorized()
=> [
throw Unauthorized()
]
}
}
Raising errors
| 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 {
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 (V23 if the route is missing).
deploy arity checks (V32) are skipped for extern targets (constructor
surface is unknown).
Pure Functions
Pure functions are top-level, stateless computations declared outside any entity. They can be called from any entity in the same file and are guaranteed to have no side effects – their output depends only on their inputs.
Syntax
pure fn name(param1: Type1, param2: Type2) -> ReturnType {
body_expression
}
The pure fn keywords introduce the function. The body is a single expression
(which can be a block) that produces the return value.
Basic Examples
pure fn max(a: u64, b: u64) -> u64 {
if a >= b { a } else { b }
}
pure fn min(a: u64, b: u64) -> u64 {
if a <= b { a } else { b }
}
pure fn clamp(val: u64, lo: u64, hi: u64) -> u64 {
if val < lo { lo }
else if val > hi { hi }
else { val }
}
Restrictions
Pure functions have strict constraints:
| 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_output(
amount_in: U256,
reserve_in: U256,
reserve_out: U256,
fee_bps: u64
) -> U256 {
let fee = compute_fee(amount_in, fee_bps);
let effective_in = amount_in - fee;
effective_in * reserve_out / (reserve_in + effective_in)
}
pure fn compute_fee(amount: U256, bps: u64) -> U256 {
amount * (bps as U256) / 10000
}
entity DEX {
routes {
swap(amount_in: U256, min_out: U256)
where amount_in > 0 : throw 100
=> [
let out = compute_output(
amount_in, m_reserve_a, m_reserve_b, 30
);
if out < min_out => [
// slippage protection
] else [
Token::transfer(msg::sender, out) ~> m_token_b
]
]
}
}
Libraries and Using
Cambrian offers two complementary ways to reuse pure helpers: named
library blocks and using … for T method-call sugar.
library Name { … }
A library is a named scope of pure fn, const, and type items only. Bodies
may not touch entity state, msg::*, or temporal references — the same purity
envelope as a free pure fn.
library SafeMath {
pure fn add(a: u256, b: u256) -> u256
where (a + b >= a) : throw Overflow() { a + b }
pure fn sub(a: u256, b: u256) -> u256
where (a >= b) : throw Underflow() { a - b }
}
Call library functions with a qualifier, or attach them via using (below).
using Lib for T
Attach every suitable function from a library as a method on type T:
using SafeMath for u256;
entity Vault {
routes {
deposit(amount: u256) => [
m_balance := m_balance.add(amount)
]
}
m_balance: u256 {
in deposit(amount) => m_balance.add(amount)
}
}
recv.fn(args) rewrites to Lib.fn(recv, args) (or the free pure fn form)
before codegen. Chained calls such as x.add(1).mul(2) work when each step’s
return type matches the next receiver.
using { fn1, fn2 } for T
Attach selected free pure fns without a library wrapper:
pure fn double(x: u256) -> u256 { x * 2 }
pure fn triple(x: u256) -> u256 { x * 3 }
using { double, triple } for u256;
entity Foo {
routes {
bump() => []
}
m_n: u256 {
in bump() => m_n.double().triple()
}
}
Rules of thumb
- The first parameter of each attached function must match the
fortype. - Method names must not collide with built-in methods on
Vec,HashMap,String, orAddress. - Libraries and free
pure fns may live in imported.camfiles. See Program Structure and the Contract Standard Library.
EVM lowering
Each Cambrian library becomes a Solidity library with internal pure
(or equivalent) functions. Call sites use LibName.fn(args). Free pure fns
outside a library remain top-level Solidity helpers.
Macros
Macros are entity-scoped helper expressions that can access member state. They provide reusable logic fragments for conditions, computations, and assertions that need to reference the entity’s persistent state.
Syntax
macro name() -> ReturnType = {
body_expression
}
Macros are declared inside an entity body, alongside routes { … } and
member fields — not inside route action lists.
Basic Examples
entity Wallet {
macro is_owner() -> bool = {
msg::sender == m_owner
}
macro is_funded(amount: U256) -> bool = {
m_balance >= amount
}
macro require_active() -> bool = {
m_status == Status::Active
}
}
Invocation
Macros are called with the @ prefix:
routes {
withdraw(amount: U256)
where @is_owner() : throw 100
&& @is_funded(amount) : throw 101
=> [
~> msg::sender with { value: amount }
]
}
The @ prefix distinguishes macro calls from pure function calls and makes it
clear that the expression accesses entity state.
Parameters
Macros can accept parameters:
macro has_allowance(owner: address, spender: address, amount: U256) -> bool = {
m_allowances.get(owner)
.unwrap_or({})
.get(spender)
.unwrap_or(0) >= amount
}
State Access
Unlike pure functions, macros can read member values. This is their primary advantage:
macro available_balance() -> U256 = {
m_balance - m_locked
}
macro is_whitelisted(account: address) -> bool = {
m_whitelist.exists(account)
}
macro current_price() -> U256 = {
if m_supply > 0 {
m_reserve * 1_000_000 / m_supply
} else {
0
}
}
Context Access
Macros can also access msg:: and sys:: context:
macro is_owner() -> bool = {
msg::sender == m_owner
}
macro is_expired() -> bool = {
sys::now > m_deadline
}
macro has_sufficient_value(required: U256) -> bool = {
msg::value >= required
}
Common Patterns
Access Control
macro is_admin() -> bool = {
msg::sender == m_admin
}
macro is_operator(account: address) -> bool = {
m_operators.exists(account)
}
State Validation
macro is_active() -> bool = {
m_status == Status::Active && sys::now < m_deadline
}
macro has_quorum(proposal_id: u64) -> bool = {
m_vote_counts[proposal_id] >= m_quorum
}
Computed Values
macro fee_for(amount: U256) -> U256 = {
amount * m_fee_rate / 10000
}
macro net_amount(amount: U256) -> U256 = {
amount - @fee_for(amount)
}
Macro Calling Macro
Macros can call other macros in the same entity:
macro can_execute(amount: U256) -> bool = {
@is_owner() && @is_funded(amount) && @is_active()
}
Where to Use Macros
Macros can appear in:
| 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
entity Multisig {
macro is_signer() -> bool = {
m_signers.exists(msg::sender)
}
macro has_enough_confirmations(tx_id: u64) -> bool = {
m_confirmation_count.get(tx_id).unwrap_or(0) >= m_required
}
macro is_pending(tx_id: u64) -> bool = {
m_executed.get(tx_id).unwrap_or(false) == false
}
routes {
submit(to: address, amount: U256)
where @is_signer() : throw 200
=> []
confirm(tx_id: u64)
where @is_signer() : throw 200
&& @is_pending(tx_id) : throw 201
=> []
execute(tx_id: u64)
where @is_signer() : throw 200
&& @is_pending(tx_id) : throw 201
&& @has_enough_confirmations(tx_id) : throw 202
=> [
~> m_transactions[tx_id].to with {
value: m_transactions[tx_id].amount
}
]
}
}
Standard Library
Cross-target helpers live under the std:: namespace. Like msg:: and sys::,
std:: is always in scope — no import is required.
Ready-to-deploy token and vault entities, plus extra integer helpers, live
in the separate Contract Standard Library
(stdlib/ in cambrian-lang). Those are ordinary .cam files you import
or list in project.yaml; they are not std:: names.
Bare legacy names such as min(a, b) or sha256(data) are rejected
(V49). Always write the qualified form.
std::math
All functions are pure. Operand types must match unless the signature documents widening (same rules as binary arithmetic).
| 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 min(...) does not replace std::math::min — call the
std symbol with the prefix.
std::str
Parsing
std::str::parse_u64(s: String, radix: u64) -> Option<u64>
std::str::parse_u8(s, radix) -> Option<u8>
std::str::parse_i64(s, radix) -> Option<i64>
std::str::parse_uint(s, radix) -> Option<u64> // alias
std::str::parse_int(s, radix) -> Option<i64> // alias
Parse all characters of s with radix in 2..=36. Leading 0x / 0X
forces base 16 (radix must be 16 or 0). Overflow, empty string, bad digit,
or bad radix yields none.
match std::str::parse_u64(s, 10) {
some(n) => n,
none => 0,
}
Formatting
std::str::format(fmt: String, ...) -> String
Phase-1 format specs:
| 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) -> [u8; 32] | SHA-256 digest |
Prefer std::crypto::sha256 for portable digests. On EVM-only call sites,
evm::sha256 remains available for explicit platform intent — see
EVM Intrinsics.
Strict typing (V50)
There is no implicit string↔number coercion in member transforms, returns, or sends:
String→ numeric member 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
deterministic_addresses: true
library_paths:
- ${CAMBRIAN_STDLIB} # directory that contains math.cam and token/
imports:
- math.cam
- token/erc20-core.cam
sources:
- MyVault.cam
// MyVault.cam
import "math.cam"
entity MyVault {
routes {
// …
}
// …
}
A path that starts with ./ or ../ is always relative to the importing
file — that is how files inside stdlib/ refer to each other
(import "./erc20-core.cam"). A path without that prefix also searches
each library_paths entry. Names from imported files join the same
program-wide namespace as your own pure fns; two files cannot declare
the same name.
To deploy a shipped entity as-is, put it on sources: instead of
importing it:
sources:
- token/ERC20.cam
- token/ERC20.test.cam
Math helpers (math.cam)
Prefer built-in std::math for min, max, clamp, and
ordinary muldiv. math.cam exists for two operations the builtins do
not cover:
| 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. |
Do not re-export min / max from this file: a user pure fn of that
name shadows the builtin for the whole program.
Tokens
ERC20 and ERC20Multi
Fungible token with transfer / transferFrom / approve, metadata
views, gated mint / holder burn, and EIP-2612 permit.
ERC20— one deployment. Use this for an application, governance, or wrapped-asset token.ERC20Multi— the same routes 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.
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 |
|---|---|---|---|
msg::sender | address | Caller of this call | msg.sender |
msg::value | integer | Native currency attached to the call | msg.value |
These are the fields you should rely on for access control and payable logic.
Optional time alias
| Field | Notes |
|---|---|
msg::timestamp | Lowers to block.timestamp on EVM. Prefer sys::now / sys::timestamp when you mean block time, so message context and system context stay distinct. |
msg::sender
macro is_owner() -> bool = {
msg::sender == m_owner
}
routes {
withdraw(amount: U256)
where msg::sender == m_owner : throw Unauthorized()
=> [
~> msg::sender with { value: amount }
]
}
In transforms:
m_last_caller: address {
in deposit(_) => msg::sender
}
m_balances: HashMap<address, U256> {
in deposit(amount) => {
let current = m_balances.get(msg::sender).unwrap_or(0);
m_balances.set(msg::sender, current + amount)
}
}
msg::value
routes {
deposit()
where msg::value > 0 : throw ZeroDeposit()
=> []
}
m_balance: U256 {
in deposit() => m_balance + msg::value
}
Routes (or transforms) that read msg::value are treated as payable on EVM so
callers can attach native currency.
Usage contexts
| 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 |
|---|---|---|
sys::now / sys::timestamp | Block / host timestamp | block.timestamp |
sys::block_number | Block number | block.number |
sys::chainid | Chain id | block.chainid |
sys::address | This entity’s address | address(this) |
sys::balance | This entity’s native balance | address(this).balance |
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 |
For property / invariant ctx blocks, the validated context names are the portable subset: msg::{sender,value} and sys::{now,timestamp,chainid,block_number,balance} (T20).
Time guards
routes {
claim()
where (sys::now >= m_unlock_time) : throw TooEarly()
=> [
~> msg::sender with { value: m_amount }
]
}
m_last_action: u64 {
in deposit(_) => sys::now
in withdraw(_) => sys::now
}
Own address and balance
routes {
sweep(to: address) => [
~> to with { value: sys::balance }
]
}
Prefer sys::balance for “this entity’s balance.” Use evm::balance(addr) when you need another account’s balance on EVM (see EVM Intrinsics).
Not for pure code
pure fn and pure routes cannot read sys::* (or msg::*) — those namespaces are impure by definition.
EVM Intrinsics (evm::)
The evm:: namespace exposes EVM-specific primitives. It is always in scope on
EVM-domain targets. Using evm::* off the EVM domain is rejected (E12).
Pure intrinsics
Usable inside pure fn bodies as well as routes:
| Intrinsic | Returns | Lowers to |
|---|---|---|
evm::ecrecover(hash, v, r, s) | address | ecrecover(...) (address(0) on bad signature) |
evm::keccak256Packed(a, b, ...) | bytes32 | keccak256(abi.encodePacked(a, b, ...)) |
evm::sha256(data) | bytes32 | sha256 precompile |
evm::ripemd160(data) | bytes20 | ripemd160 precompile |
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) | bytes32 | blockhash(n) |
Example (EIP-2612 style)
routes {
permit(
owner: address,
spender: address,
value: U256,
deadline: U256,
v: u8,
r: bytes32,
s: bytes32
)
where sys::timestamp <= deadline : throw Expired()
=> [
let digest = evm::keccak256Packed(/* EIP-712 fields… */);
let recovered = evm::ecrecover(digest, v, r, s);
// … compare recovered to owner, update allowance …
]
}
Portable digests
Prefer std::crypto::sha256 when the same source should stay meaningful across
targets. Use evm::sha256 when you specifically want the EVM precompile at the
call site.
Multi-File Projects
A project.yaml describes a multi-file Cambrian project: which .cam sources
to load, which backend to target, where to write output, and optional test /
Foundry / Lean knobs.
CLI
cambrian-transpiler --project path/to/project.yaml
Entry sources listed under sources: are merged into one program. Transitive
import "./other.cam" edges are followed automatically — shared helper
files need not be enumerated. Optional library_paths: / imports: pull
helpers from a search root (for example the contract standard
library) without listing them as project entries.
Minimal example
name: uniswap-v2
target: evm
deterministic_addresses: true
output_dir: build/
sources:
- ERC20.cam
- UniswapV2Pair.cam
- UniswapV2Factory.cam
Common keys
| 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/) |
deterministic_addresses | When true, EVM uses CREATE2 + auto CambrianFactory (see Deterministic Addresses) |
foundry | Foundry / solc options (optimizer, via_ir, remappings, …) |
fuzz | Shared fuzz defaults (runs, seed, shrink, …) |
invariant | Shared invariant defaults (runs, depth, fail_on_revert, …) |
lean | Lean-target knobs (numerics, proof_helpers, intrinsics, …) |
Foundry remappings
When using @solidity_import on an extern entity:
foundry:
optimizer: true
via_ir: true
remappings:
- "@openzeppelin/=lib/openzeppelin-contracts/"
- "forge-std/=lib/forge-std/src/"
Tests
fuzz:
runs: 64
seed: 1
shrink: true
invariant:
runs: 16
depth: 16
fail_on_revert: false
Imports vs project sources
sources:— entities and their tests, fuzz, and invariants. These are the files you own and deploy.imports:— shared helpers listed in YAML (pure fn, types,library,event/error,extern entity, …). Looked up next to the YAML, then in 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 emission options), see the reference page project.yaml.
Phased Routes
Most routes are unphased: every member transform runs as one atomic
step, then every effect (~>, deploy, emit, …) runs afterward. That
covers the common case and matches the checks–effects–interactions (CEI)
discipline on EVM.
Reach for named phases only when you need something the unphased model cannot express:
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.
Otherwise keep the route unphased. Fewer phases are easier to read and audit.
Syntax
A phased route body is a sequence of tag: [ … ] blocks. Phases run in
declaration order:
castVote(proposal_id: U256, support: u8) => [
read: [
var weight = getVotes(msg::sender) ~> m_token;
]
tally where weight > 0 : throw 100 : [
]
]
Member transforms that participate in a phased route must name the same phase tag:
m_for: HashMap<U256, U256> {
in castVote(proposal_id, support) =>
tally: if support == 1 {
m_for.update(proposal_id, m_for[proposal_id] + weight)
} else {
m_for
}
}
Prefer unphased on EVM
For an unphased route the EVM backend flushes all member SSTOREs before any external call. You get CEI without writing phases:
transfer(to: address, amount: U256)
where amount > 0 : throw 1
=> [
emit Transfer(msg::sender, to, amount);
]
Balances update in the member transforms; emit (and any ~>) runs after
storage is consistent. Prefer this shape whenever you do not need a
captured return value mid-route.
When phases are the right tool
| Need | Pattern |
|---|---|
| Read a remote view, then gate local writes | fetch: with var x = … ~> dest, then act where … |
| 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 |
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 effects of Pᵢ
(effects see post-transform state_Pᵢ)
Members without a transform at Pᵢ keep their value from Pᵢ₋₁.
Atomicity inside a phase
Transforms in the same phase are computed from the same pre-phase snapshot and written together. Two members can swap without observing a half-updated state:
m_a: u64 { in example() => p1: m_b }
m_b: u64 { in example() => p1: m_a }
After p1, m_a and m_b have exchanged their previous values.
var capture
A phase action may bind the return value of a synchronous external call:
fetch: [
var bal = balanceOf(who) ~> m_token;
]
The var is in scope from the moment its ~> runs through all later
phases of the same invocation. Later transforms and per-phase where
clauses may read it; earlier phases and the route-level where may not.
Per-phase where (V27 / V28)
Attach a precondition to a later phase with:
phaseName where (cond) : throw N : [ … ]
The condition lowers to a require(...) at the top of that phase —
after previous phases have finished (so earlier vars are bound) and
before this phase’s transforms or effects.
withdraw(who: address) => [
fetch: [
var bal = balanceOf(who) ~> m_token;
]
act where bal > 0 : throw 401 : [
]
]
Scoping rules:
| 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 example() => p1: m_b + 1 }
m_b: u64 { in example() => p1: m_a + 1 }
m_c: u64 { in example() => p1: ^m_a + ^m_b }
^m_a and ^m_b are the results of the first two transforms, so
m_c receives (m_b + 1) + (m_a + 1). Without ^, both names would
still be the pre-phase snapshot.
Visibility summary
| Context | m_x | ^m_x |
|---|---|---|
| Unphased transform | Pre-route | Post-route |
| Phase Pᵢ transform | Pre-Pᵢ (incl. prior phases) | Post-Pᵢ |
| Effect in phase Pᵢ | Post-Pᵢ | Not available in effects |
Effects always see the post-transform state of their own phase; temporal
^ is a transform-only construct.
Identity Members
Identity members are immutable fields fixed at deploy time. Under
deterministic_addresses: true they participate in the CREATE2 address of
the instance: same identity args ⇒ same address everywhere in the project.
Declaration
entity Locker {
identity m_id: u64
routes {
constructor() => []
// …
}
}
An entity may declare several identity members; all contribute to the address, in declaration order:
entity UniswapV2Pair {
identity m_token0: address
identity m_token1: address
}
Rules
| 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) in deterministic mode uses the same arity (rule
V33). Mismatched arity is a compile-time error (V32 for deploy,
V33 for from).
Singletons
An entity with no identity members is a singleton: one predictable
address per project, and Entity.address() takes no arguments:
entity UniswapV2Factory {
// no identity — singleton
routes {
constructor(fee_to_setter: address) => []
createPair(tokenA: address, tokenB: address) => [
deploy UniswapV2Pair(min_addr(tokenA, tokenB),
max_addr(tokenA, tokenB))
]
}
}
// elsewhere:
UniswapV2Factory.address()
Governor and TimelockController in examples/governor/ are singletons the
same way.
When to use identity
Use identity when distinct instances must be content-addressed —
token ids, pair keys (token0, token1), shard indices. If the value can
change after deployment or should not affect the address, use an ordinary
member instead.
See Deterministic Addresses for CREATE2 /
CambrianFactory details.
Deterministic Addresses
With deterministic_addresses: true in project.yaml, every entity in the
project gets a predictable CREATE2 address derived from its identity
arguments. Cross-entity sends, from checks, and deploy all share that
same prediction — no hand-rolled factory.
name: det-messaging
target: evm
deterministic_addresses: true
output_dir: build/
sources:
- det_guardian.cam
- det_locker.cam
CREATE2 and CambrianFactory
The EVM backend emits a project-local CambrianFactory. Addresses are
derived from:
- the entity’s deploy bytecode (constructor args = identity fields),
- a per-deployer factory salt of
0, - the fixed factory address known at generation time.
Because the factory address itself is deterministic, every entity can compute every other entity’s address from identity fields alone.
Authors never write a custom factory: opting into
deterministic_addresses: true is the only configuration required.
Entity.address(args) and addressOf
Prefer the dot form. An addressOf(…) spelling exists as well; both lower
to the same CREATE2 expression:
acceptPing() ~> Locker.address(locker_id)
Arity equals the identity-member count (Identity Members).
Singletons use Entity.address() with no arguments.
Use Entity.address(…) anywhere you need a predicted address: send
destinations, member initializers, from comparisons.
from Entity(args)
In deterministic mode, sender authentication compares msg.sender to the
same CREATE2 expression:
acceptPing()
from Guardian(m_id)
=> []
Here Locker only accepts pings from the Guardian instance whose
identity matches this locker’s m_id. Non-deterministic mode restricts
from Entity(…) to a single address argument (V33); prefer
deterministic mode for multi-entity systems.
deploy Entity(…)
deploy goes through CambrianFactory:
- CREATE2-deploy with 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(…) | Equivalent spelling; same CREATE2 |
from Entity(args) | msg.sender == that CREATE2 address |
deploy Entity(args) | Factory CREATE2 + guarded initialize |
Keep identity, addressing, authentication, and deployment aligned — one argument list, one address, everywhere.
Type Widening
When an arithmetic or comparison expression mixes numeric types, Cambrian widens both operands to a common type that can represent every value of either operand. No explicit cast is required for ordinary same-sign mixes.
Rules
Rule 1 — Same sign
Result width is max(width); signedness is unchanged:
m_total: u128 + amount: u64 // amount widened to u128
m_x: i32 + m_y: i64 // m_x widened to i64
Rule 2 — Mixed signedness
Result is signed with
width = max(2 × unsigned_width, signed_width):
m_count: u32 + delta: i8 // both → i64
m_val: u64 + offset: i64 // both → i128
Doubling the unsigned width keeps the full unsigned range representable in the signed result.
Rule 3 — u128 / U256 mixed with signed
There is no wider signed primitive than i128. Mixing u128 or U256
with any signed type is a compile-time error:
m_a: u128 + d: i8 // ERROR
m_b: U256 + d: i32 // ERROR
Any unsigned type combined with U256 widens to U256. Integer literals
adapt automatically: m_balance + 1 with m_balance: U256 treats 1 as
U256.
Full widening table
Cell at row A, column B is the result of A op B. Symmetric cells are
omitted; ERR means compile-time error.
| 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— fine, but larger than most balance fields; cast deliberately if you need a narrower store. - 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 Panic(0x11) |
Widening as is a no-op (zero- or sign-extend). Wrap-around is only
+% / -% / *%.
pure fn checked_u8_add(a: u8, b: u8) -> u8 {
a + b // reverts on EVM if a + b > 255
}
pure fn checked_div(a: U256, b: U256) -> U256 {
a / b // reverts if b == 0
}
Use checked ops for balances, supplies, counters, and any path where silent wrap-around would be a bug.
std::math::{divc,divr,divmod,muldiv} also fail on a zero divisor on every
target (independent of lean.numerics).
Wrapping operators
pure fn wrapping_u8_add() -> u8 {
(255 as u8) +% 1 // 0
}
pure fn wrapping_u8_sub() -> u8 {
(0 as u8) -% 1 // 255
}
pure fn wrapping_u8_mul() -> u8 {
(255 as u8) *% 2 // 254
}
Signed wrapping is two’s complement:
pure fn wrapping_i8_inc() -> i8 {
(127 as i8) +% 1 // -128
}
There is no /% or %%.
EVM lowering
On --target evm, wrapping ops lower to per-entity internal pure helpers
_wadd / _wsub / _wmul whose bodies are unchecked { … }. That preserves
Solidity 0.8 wrap-around without turning off checked arithmetic elsewhere
in the contract. Helpers are emitted only when the entity uses a wrapping op.
The helpers take uint256; a narrower result is truncated with an explicit
Solidity cast (so u8 wrap-around is the low 8 bits).
Canonical use: the Uniswap V2 cumulative-price oracle
(examples/uniswap-v2/UniswapV2Pair.cam) accumulates
time_elapsed * uqdiv(…) with +% so UQ112x112 math can overflow the
112-bit boundary by design.
Lean: lean.numerics
Source +/-/* stay checked as above. How --target lean models
those operators is selected in project.yaml. Wrapping ops +%/-%/*%
are wrapping infix in every BitVec mode.
lean.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, i8–i128 | yes | yes |
U256 | yes | yes |
Interaction with widening
Overflow checking (or wrapping) applies to the result type after
widening. Adding two u8 values into a u16 context
widens first, so checked + cannot overflow for any pair of u8s. An
explicit wider cast is an alternative to wrapping when you want the full
mathematical result.
Mixing operators
Each operator decides independently:
pure fn mix_wrap_then_checked(a: u8, b: u8, c: u8) -> u8 {
(a +% b) * c // wrap the sum, then checked multiply
}
Choosing
| 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|project.yaml> -o <dir> --target evm|lean
The default target is evm. In project.yaml, set target: evm or target: lean. Unknown names are rejected — there is no silent fallback to a default.
Domain × language
| 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–L14).
On the EVM domain, evm::* intrinsics and E-family rules apply. Lean keeps property blocks as theorems; --target evm expands them into concrete tests and fuzz cases. Multi-entity invariants are accepted on both public targets.
Informal names vs CLI vs output
| Informal 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 only warns.
Capability matrix
| Capability | evm | lean |
|---|---|---|
Models EVM (evm::*, CREATE2, payable, …) | yes | yes |
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 proofs may besorry).
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 (when enabled) CREATE2 addresses.
Output layout
Single file:
out/
src/Counter.sol # entity contract
test/… # Foundry tests (if .cam has test/property/invariant)
foundry.toml
setup.sh
Multi-file project (project.yaml):
out/
src/_<project>_project.sol # combined program
src/<Entity>.sol # thin import stubs per entity
test/…
foundry.toml
cambrian-transpiler project.yaml -o /tmp/out-evm
cd /tmp/out-evm && forge test
Never invoke solc directly for Cambrian projects — use forge build / forge test.
Foundry configuration
The emitter writes foundry.toml with [profile.default] plus tiered profiles:
| 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
With deterministic_addresses: true in project.yaml:
- The backend emits a
CambrianFactory(andICambrianFactory) that deploys via CREATE2. - Entity constructors split into a deploy-time stub plus a factory-guarded
initialize(). Entity.address(args)/addressOf<Entity>(...)lower to the same CREATE2 prediction used in sends andfromclauses.
Singletons (no identity members) get a fixed address; Entity.address() takes no arguments.
Without deterministic mode, from Entity(addr) expects a single address argument (rule V33).
Payable auto-detection
There is no Cambrian payable keyword. Solidity payable is inferred:
- Any route whose body (or reachable AST) reads
msg::valuebecomespayable. receiveis alwayspayable;fallbackispayableonly when it readsmsg::value.- Constructors are emitted
payablesodeploy … with { value: … }works.
Events and errors (ABI)
Cambrian event / error declarations become Solidity ABI items:
event Transfer(from: address indexed, to: address indexed, amount: U256)
error InsufficientBalance(have: U256, need: U256)
routes {
withdraw(amount: U256)
where (m_balance >= amount) : throw InsufficientBalance(m_balance, amount)
=> [
emit Transfer(msg::sender, msg::sender, amount)
]
}
emit Name(args)→emit Name(args);throw Name(args)→revert Name(args);- At most three
indexedevent parameters (V36). - Unphased routes keep SSTORE-before-CALL ordering (checks-effects-interactions).
Project knobs (EVM)
name: my-dapp
target: evm
deterministic_addresses: true
foundry:
solc_version: "0.8.24"
# remappings, fuzz_runs, profiles, …
fuzz:
runs: 256
invariant:
runs: 256
depth: 50
See project.yaml and Testing backends.
Lean-EVM
--target lean emits a Lean 4 + Lake project that models the same EVM domain as --target evm: synchronous calls, CREATE2-style addresses, balances, and world-threaded state. Each property becomes a theorem over the full parameter space rather than a Foundry-style fuzz test.
Lake project layout
out-lean/
lakefile.toml
lean-toolchain
Cambrian.lean
Cambrian/
SimpAttrs.lean # vendored prelude pieces
… # Cambrian.Prelude modules
Generated/
World.lean # World / BlockEnv / call model
Pure.lean # pure fns (when present)
Extern.lean # extern entity axioms (when present)
Dispatch.lean # multi-entity dispatch (when needed)
Counter.lean # entity state + helpers
CounterRoutes.lean # route transitions
CounterSpec.lean # tests / properties / invariants as theorems
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean
cd /tmp/out-lean && lake build
# Or shell out after emit:
cambrian-transpiler contracts/counter.cam -o /tmp/out-lean --target lean --check-lean
--check-lean only runs lake build when the target is lean; other targets print a warning.
EVM-domain semantics (high level)
The Lean model mirrors EVM behaviour that the Solidity backend implements:
- World state threads through routes (
WorldState/ balances / block env). - Typed sends and self-calls update the world; raw value transfers fail closed when underfunded.
- Deterministic addresses use the same CREATE2 helpers as
Entity.address(...). - Init / factory-style installation projects constructor args into
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; this release does not discharge main goals.
| Placement | Policy |
|---|---|
Main theorems / lemmas in *Spec.lean | sorry in the proof body 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 |
| L12 | TVM-only rescue / recover (async bounce recovery) is dropped on Lean — warning |
| L13–L14 | Missing Lean lowering for some stdlib / unresolved types |
Forced codegen for L9/L11 emits a non-compiling -- L9: / -- L11: sentinel rather than silently omitting the check.
When to use Lean
Use Lean when you want machine-checked statements of the EVM model next to Foundry oracles — not as a substitute for forge test. Keep the same .cam fixtures across Domain EVM cores wherever Lean accepts the surface.
Testing Overview
Cambrian embeds a test language in .cam sources. The same declarations lower to Foundry tests or Lean theorems depending on the target — you do not maintain a separate Solidity or Lean test suite by hand.
Write tests next to the entity they exercise (same file or a sibling *.test.cam merged via project.yaml). Validation rules T* / I* catch arity, context, and forall mistakes before codegen.
The triad
| 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 }
fuzz { }
}
invariant "count stays non-negative" for Counter {
init { m_count: 0 }
action increment(amount: u64) { bound amount in 0..1000 }
action reset() { }
check m_count >= 0
}
How they relate
- A
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;msg::/sys::live inctx { … }(T22). 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. 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/ctx |
| 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) |
ctx { msg::x: …, sys::y: … } | Preferred place for blockchain context on properties/invariants; plain tests often still use inline msg { } / sys { } blocks |
let name = expr | Binding visible for the rest of the test |
msg { … } / sys { … } | Per-step message / system context overrides |
call route(args) | Invoke a route (arity must match) |
expect state { … } | Assert members after a call |
expect throw N / expect throw Name(…) | Assert revert / error |
expect return E | Assert return payload (route must declare -> T) |
expect effects […] | Assert outgoing effects (backend-dependent; prefer state/return on EVM) |
On Lean, every expect after a call is kept and conjoined in the theorem goal.
Lenses
Nested fields, map keys, and tuple slots use path syntax:
expect state { m_pools[0].reserve_a: 1500 }
expect state { m_allowances[owner][spender]: 500 }
expect state { m_pair.0: 10 }
expect return.0 == 0
expect return.len == 3
expect return[0].name == 42
Enums / options:
expect state { m_state: State::Funded }
expect state { m_resolution: some("resolved") }
HashMap literals:
expect state { m_balances: { alice => 900, bob => 100 } }
expect state { m_balances[alice]: 900 }
Multi-step tests
State threads automatically: output of one call is input to the next, with or without an intervening expect.
test "multi-step" for Counter with { m_count: 0 } {
call increment(1)
expect state { m_count: 1 }
call increment(1)
expect state { m_count: 2 }
}
skip from vs authentic senders
Routes with from Entity(…) verify msg.sender. For unit tests of business logic, skip from disables that check. For end-to-end sender checks on EVM, set msg { sender: … } to the address the from clause expects (often Entity.address(…) under deterministic mode).
with vs ctx (brief)
with/ state pins — entity members (m_count: 0).ctx—msg::sender,msg::value,sys::now,sys::balance, …. Puttingmsg::insidewithis rejected (T22).
Plain test blocks historically also use msg { } / sys { } bodies; properties and invariants prefer the explicit ctx { } record. See Properties.
Properties and Fuzz
A property is Cambrian’s abstract, parameterised statement about an entity. It is the canonical construct for property-based testing and for Lean specifications.
Shape
- 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 } with { m_count: 5 }
}
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 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
shrink: true
max_local_rejects: 1024
Threaded into Foundry [profile.default.fuzz].
Invariants
Stateful invariants fuzz sequences of actions: the harness picks from a declared action pool, applies each call, and re-checks boolean predicates after every successful step. Use them for state-machine bugs that single-call properties miss.
Single-entity form
invariant "count never exceeds bound" for Counter {
init { m_count: 0 }
senders { 0xAA00000000000000000000000000000000000001,
0xBB00000000000000000000000000000000000002 }
action increment(amount: u64) {
bound amount in 0..1000
}
action reset() { }
check m_count <= 1_000_000_000
}
invariant "reset must succeed" for Counter
#[fail_on_revert]
{
action increment(amount: u64) { bound amount in 0..100 }
action reset() { }
check m_count >= 0
}
| 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 |
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 |
invariant "deposits lead withdrawals" for Vault {
init { m_balance: 0 }
action deposit(amount: u64) {
bound amount in 1..100
assume trace::length < 10
}
action withdraw(amount: u64) {
bound amount in 1..100
assume m_balance >= amount
assume !trace::lastWas(withdraw)
assume trace::count(withdraw) <= trace::count(deposit)
}
check m_balance >= 0
check trace::count(deposit) >= trace::count(withdraw)
}
Accumulators are emitted only when referenced (Lean TraceAcc; Foundry handler counters).
track / derived / excludes (Foundry-rich)
invariant "borrowed never exceeds total assets" for LendingPair
#[tag("INV-LEND-001")]
#[runs(2000)]
#[depth(80)]
#[with_time]
{
init { m_total_assets: 1000, m_borrowed: 0 }
track {
let initial_total = m_total_assets
}
derived utilization() -> u128 {
return (m_borrowed * 100) / m_total_assets
}
exclude senders { 0x0000000000000000000000000000000000000001 }
exclude selectors { accrueInterest }
action deposit(amount: u128) { bound amount in 1..m_total_assets }
action withdraw(amount: u128) {
skip if m_total_assets == 0
bound amount in 1..m_total_assets
}
action accrueInterest() { advanceTime(3600) }
check m_borrowed <= m_total_assets
}
| Attribute / 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
invariant "vault sum covers treasury" for { v: Vault, a: Vault, t: Treasury } {
init v { m_balance: 0 }
init a { m_balance: 0 }
init t { m_total: 0 }
action v.deposit(amount: u64) { bound amount in 0..1000 }
action a.deposit(amount: u64) { bound amount in 0..1000 }
action t.credit(amount: u64) { bound amount in 0..1000 }
check v.m_balance + a.m_balance >= t.m_total
}
Actions and member refs must be qualified (I9/I11). Supported on Foundry and Lean.
Project defaults
invariant:
runs: 256
depth: 50
fail_on_revert: false
seed: 0
max_local_rejects: 1024
Per-invariant attributes override the global fail_on_revert / runs / depth where applicable.
Testing Backends
Public targets drive several oracles from the same .cam fixtures. This chapter summarises how each backend runs and what it supports.
Foundry (forge test)
Target: --target evm
- Emits
test/*.t.solplusfoundry.toml(andsetup.sh). - Unit
test→ Soliditytest_*functions withvm.prank/ storage setup. - Desugared
propertyfuzz →function testFuzz_*withvm.assume/bound. invariant→Handler_*+Invariant_*TestwithtargetContract/targetSelector/targetSender.- Profiles:
default,cambrian(CI),cambrian_night(stress).
cambrian-transpiler project.yaml -o /tmp/out && cd /tmp/out
forge test
FOUNDRY_PROFILE=cambrian forge test
Lean Spec theorems
Target: --target lean (+ optional --check-lean)
test/property/invariant→ theorems inCambrian/Generated/<E>Spec.lean.- Each
propertybecomes a theorem; fuzz sampling ranges never appear in the statement. - Main proofs may be
sorry; 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 |
bytes32, String | yes |
Unsupported combinations warn (W7) instead of hard-failing.
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 in a project or passed beside the entity):
| 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
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 {
event Transfer(indexed src: address, indexed dst: address, value: U256);
event Approval(indexed owner: address, indexed spender: address, value: U256);
error InsufficientBalance(have: U256, need: U256);
error Unauthorized();
error ZeroAmount();
routes {
constructor(name: String, symbol: String, decimals: u8,
initial_supply: U256) => []
transfer(to: address, amount: U256)
where amount > 0 : throw ZeroAmount() => [
if balance_of(m_balances, msg::sender) < amount => [
throw InsufficientBalance(
balance_of(m_balances, msg::sender), amount)
]
emit Transfer(msg::sender, to, amount);
]
approve(spender: address, amount: U256) => [
emit Approval(msg::sender, spender, amount);
]
view balanceOf(owner: address) -> U256 => [
return(balance_of(m_balances, owner))
]
view totalSupply() -> U256 => [
return(m_total_supply)
]
}
// members below…
}
where / throw Name(…) pair with declared errors; emit pairs with
declared events. Up to three indexed fields per event on EVM (V36).
HashMap balances
m_balances: HashMap<address, U256> {
in constructor(_, _, _, initial_supply) => {
let sender = msg::sender;
{}.insert(sender, initial_supply)
}
in transfer(to, amount) => {
let sender = msg::sender;
let sender_bal = balance_of(m_balances, sender);
let to_bal = balance_of(m_balances, to);
m_balances
.update(sender, sender_bal - amount)
.update(to, to_bal + amount)
}
}
Maps are persistent values: .insert / .update return a new map; the
transform’s final expression becomes the stored member. Empty map
literal: {}.
Nested allowances
m_allowances: HashMap<address, HashMap<address, U256>> {
in approve(spender, amount) => {
let sender = msg::sender;
let inner = if m_allowances.exists(sender) {
m_allowances[sender]
} else {
{}
};
m_allowances.update(sender, inner.update(spender, amount))
}
}
transferFrom decrements the inner map the same way (see
contracts/token.cam for the full mint / burn / allowance path).
Patterns worth copying
| 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 | Baseline entity (numeric throws, no events) |
contracts/erc20_events_evm.cam | Event / emit patterns |
contracts/erc20_errors_evm.cam | Named errors |
Add these paths to a project.yaml with target: evm to generate Foundry
harnesses — same workflow as Counter.
Escrow
Two-party escrow with an arbiter and a deadline. Prefer the enum-based
contracts/escrow_v2.cam; contracts/escrow.cam is the same machine with
u8 state codes.
State machine
Created --fund()--> Funded
Funded --release()--> Released (pay seller)
Funded --refund()--> Refunded (pay buyer)
Funded --claimTimeout()--> Refunded (after deadline)
Funded --dispute()--> Disputed
Disputed --resolve(pct)--> Resolved (split by %)
Enum version (excerpt)
type Amount = U256
type Timestamp = u64
pure fn is_party(sender: address, buyer: address, seller: address) -> bool {
sender == buyer || sender == seller
}
entity EscrowV2 {
enum State {
Created,
Funded,
Released,
Refunded,
Disputed,
Resolved
}
routes {
constructor(buyer: address, seller: address, arbiter: address,
amount: Amount, deadline: Timestamp) => []
fund()
where m_state == State::Created : throw 10 &&
msg::sender == m_buyer : throw 11 => []
release()
where m_state == State::Funded : throw 10 &&
msg::sender == m_buyer : throw 11 => [
~> m_seller
]
refund()
where m_state == State::Funded : throw 10 &&
msg::sender == m_seller : throw 12 => [
~> m_buyer
]
claimTimeout()
where m_state == State::Funded : throw 10 &&
msg::sender == m_buyer : throw 11 &&
msg::timestamp > m_deadline : throw 16 => [
~> m_buyer
]
dispute()
where m_state == State::Funded : throw 10 &&
is_party(msg::sender, m_buyer, m_seller) : throw 14 => []
resolve(to_buyer_pct: u8)
where m_state == State::Disputed : throw 10 &&
msg::sender == m_arbiter : throw 13 => [
let buyer_amount = m_amount * to_buyer_pct as Amount / 100;
let seller_amount = m_amount - buyer_amount;
if buyer_amount + seller_amount == m_amount => [
~> m_buyer
~> m_seller
]
]
getStatus() -> (u8, Amount, Timestamp) => [
let state_code = match m_state {
State::Created => 0,
State::Funded => 1,
State::Released => 2,
State::Refunded => 3,
State::Disputed => 4,
State::Resolved => 5
};
return(state_code, m_amount, m_deadline)
]
}
m_state: State {
in constructor(_, _, _, _, _) => State::Created
in fund() => State::Funded
in release() => State::Released
in refund() => State::Refunded
in claimTimeout() => State::Refunded
in dispute() => State::Disputed
in resolve(_) => State::Resolved
}
m_resolution: Option<String> {
in constructor(_, _, _, _, _) => none
in resolve(_) => some("Resolved by arbiter")
}
}
Buyer / seller / arbiter / amount / deadline members are set once in
constructor (full file in the repo). Plain ~> dest transfers value;
the arbiter’s resolve splits by percentage with a conservation check.
Why enums
escrow.cam (u8) | escrow_v2.cam (enum) | |
|---|---|---|
| State 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 |
List them in project.yaml with target: evm to emit Foundry tests — see
Counter.
Multi-Entity Messaging
A minimal two-entity system with deterministic CREATE2 addresses,
deploy, typed sends, and from Entity(args) authentication. Source:
contracts/det_guardian.cam, contracts/det_locker.cam, and
contracts/det_messaging.yaml.
Project
name: det-messaging
target: evm
deterministic_addresses: true
output_dir: build/
sources:
- det_guardian.cam
- det_locker.cam
cambrian-transpiler --project contracts/det_messaging.yaml
Both entities declare identity m_id: u64, so
Guardian.address(id) and Locker.address(id) are stable CREATE2
predictions. The auto-generated CambrianFactory deploys them.
Architecture
Guardian(id) Locker(id)
+------------------+ +------------------+
| spawnLocker(id) | --deploy--> | (new instance) |
| ping(locker_id) | ----~>----> | acceptPing() |
| | | from Guardian(m_id)
+------------------+ +------------------+
Matching identity values: a locker with m_id = 7 only accepts
acceptPing from Guardian(7).
Guardian
entity Guardian {
identity m_id: u64
routes {
constructor() => []
ping(locker_id: u64) => [
acceptPing() ~> Locker.address(locker_id)
]
spawnLocker(locker_id: u64) => [
deploy Locker(locker_id)
]
getPings() -> u64 => [
return(m_pings_sent)
]
}
m_pings_sent: u64 {
in constructor() => 0
in ping(_) => m_pings_sent + 1
}
}
deploy Locker(locker_id)— factory CREATE2; address 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 {
constructor() => []
acceptPing()
from Guardian(m_id)
=> []
getPingCount() -> u64 => [
return(m_ping_count)
]
}
m_ping_count: u64 {
in constructor() => 0
in acceptPing() => m_ping_count + 1
}
}
from Guardian(m_id) lowers to
require(msg.sender == CREATE2(Guardian, m_id)) in deterministic mode —
sender type and identity in one clause.
Patterns
| 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, deterministic_addresses: true
project.lean.yaml
ERC20Votes.cam (+ .test / .fuzz / .invariant)
TimelockController.cam (+ .test / .invariant)
Governor.cam (+ .test / .fuzz / .invariant)
ref/ # vendored OZ reference
build/ # generated (gitignored)
Entities
| 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:
constructor(token: Address<ERC20Votes>,
timelock: Address<TimelockController>,
admin: address,
voting_period: u64,
quorum_votes: U256) => []
Idioms exercised
Phased var capture — read voting power, then tally only if weight
is positive (Phased Routes):
castVote(proposal_id: U256, support: u8) => [
read: [
var weight = getVotes(msg::sender) ~> m_token;
]
tally where weight > 0 : throw 100 : [
]
]
Role from on members — Timelock gates schedule / execute / cancel:
schedule(...) from m_proposer : throw 1
where delay >= m_min_delay : throw 2
=> []
execute(...) from m_executor : throw 3 => [
~> target with { value: value, data: data }
]
Unphased execute relies on EVM SSTORE-before-CALL so reentry cannot
re-run a finished op.
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):
cambrian-transpiler --project examples/governor/project.lean.yaml
Full commands and status live in examples/governor/README.md.
Uniswap V2
Showcase port of Uniswap V2 Core (UniswapV2Factory, UniswapV2Pair,
test ERC20) under examples/uniswap-v2/ in cambrian-lang. First
in-repo exercise of two-field identity CREATE2, deep phased routes
with cross-contract var capture, and EIP-2612 permit via evm::.
Layout
examples/uniswap-v2/
project.yaml # deterministic_addresses: true, via_ir
project.lean.yaml
shared.cam # imported pure helpers (min/max/mul_div)
ERC20.cam (+ .test / .fuzz / .invariant)
UniswapV2Pair.cam (+ .test / .fuzz / .invariant)
UniswapV2Factory.cam (+ .test / .invariant)
ref/ # upstream V2 + FlashCallback
build/
Entities
| 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(tokenA, tokenB), max(tokenA, tokenB)).
Phased liquidity and swaps
mint / burn / sync / skim capture both token balances in a
read: phase, then write reserves or payouts in a later phase. swap
is three phases — optimistic transfer, optional flash callback, then
K-invariant gate:
swap(amount0_out: U256, amount1_out: U256, to: address, data: CamData)
where amount0_out > 0 || amount1_out > 0 : throw 200
/* … reserve bounds … */
=> [
optimistic: [
transfer(to, amount0_out) ~> m_token0
transfer(to, amount1_out) ~> m_token1
]
callback: [
~> to with { value: 0, data: data }
var bal0 = balanceOf(sys::address) ~> m_token0;
var bal1 = balanceOf(sys::address) ~> m_token1;
]
check where k_holds(bal0, bal1, m_reserve0, m_reserve1,
amount0_out, amount1_out) : throw 203 : []
]
Per-phase where on check: sees post-callback bal0 / bal1
(Execution Model).
ERC-20 extras
- EIP-2612
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. Lean: project.lean.yaml. Details and the closed gap
log (G-U1…G-U10) are in examples/uniswap-v2/README.md and
PLAN.md.
Standard Library Reference
Cross-target helpers live under std::. Bare calls such as min(a, b) or sha256(data) are rejected (V49) — use the qualified forms.
Authoritative tables also live in cambrian-lang docs/STDLIB.md.
std::math
| Function | Role |
|---|---|
min, max, minmax | Ordering |
abs, sign | Sign |
clamp | Range clamp |
muldiv, muldivmod | Widening multiply-then-divide |
divmod, divc, divr | Division variants |
modpow2, pow | Powers |
std::str
| Function | Role |
|---|---|
parse_u64 / parse_u8 / parse_i64 / parse_uint / parse_int | String → Option<T> |
format | Format string |
String ↔ number conversion is never implicit (V50): use parse_* / format.
match std::str::parse_u64(s, 10) {
some(n) => n,
none => 0,
}
std::crypto
| Function | Role |
|---|---|
sha256 | Hash bytes / string → digest |
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:
| 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 |
receive / fallback | Reserved route names on EVM (Receive and Fallback) |
where / from / throw | Preconditions / sender check / failure |
return | Return from a route |
call | Invoke a private route from an action list |
deploy | Deploy another entity |
emit | Emit an event |
event / error | Declarations |
library / using / for | Libraries, using Lib for T, expression/action for |
pure fn | Top-level pure function |
const / type / record / enum | Declarations |
macro | Entity macros (@name()) |
match / if / else | Control flow |
let / var | Bindings (var for capturing ~> returns) |
in | Member transforms; bounds in bound x in lo..hi |
some / none | Option constructors |
test / property / fuzz / invariant | Testing |
expect / assume / bound / check / action / with / ctx | Test / invariant vocabulary |
import / use | File import (import "./…" or bare import "token/core.cam") / namespace (use …) |
indexed | Event parameter indexing (EVM) |
identity | Identity member marker |
skip | Test modifier (skip from) or invariant guard |
Operators such as ~>, ^, =>, &&, ||, +% are listed under
Operator Precedence.
TVM-only syntax (rescue, recover, gosh::, …) is intentionally omitted
from this public reference.
Operator Precedence
Expression operators bind as in cambrian-transpiler/src/cambrian.lalrpop
(lowest to highest). All listed binary operators are left-associative.
| Level | Operators | Notes |
|---|---|---|
| 1 | || | Logical or |
| 2 | && | Logical and |
| 3 | | | Bitwise or — not route/action syntax |
| 4 | ^ | Bitwise xor — not temporal ^member |
| 5 | & | Bitwise and |
| 6 | == != | Equality |
| 7 | < > <= >= | Ordering |
| 8 | << >> | Shifts |
| 9 | + - +% -% | Checked / wrapping add & sub |
| 10 | * / % *% | Checked / wrapping mul, div, mod |
| 11 | as Type | Cast (chains left: x as u64 as U256) |
| 12 | ! - * | Unary not, negation, dereference |
| 13 | .field .method() [index] | Postfix access |
| 14 | Primary | Literals, ^member, @macro(), msg::, sys::, calls |
Not expression operators
These appear in routes, transforms, or actions — they do not participate in the expression precedence chain above:
| Form | Role |
|---|---|
=> | Route body, transform, match arm, action-level for |
~> | Send / transfer action |
with { … } | Send / deploy options after ~> |
Temporal ^member
^m_count is parsed as a primary expression (prefix on a member name).
It is unrelated to bitwise xor between two ordinary expressions.
Send options use with { value: … } after ~> dest. See Send Messages.
For the full grammar, see cambrian-transpiler/src/cambrian.lalrpop (ExprOr
through ExprPrimary).
Transpiler CLI
cambrian-transpiler [OPTIONS] <INPUT.cam>
cambrian-transpiler --project <project.yaml> [OPTIONS]
Targets
--target | Output |
|---|---|
evm (default) | Flat Solidity + Foundry harness |
lean | Lean 4 Lake project |
Flags
| Flag | Description |
|---|---|
-o <dir> | Output directory |
--project <file> | Load a multi-file project.yaml (target, sources, optional library_paths / imports) |
--target <name> | Select backend |
--dump-ast | Parse, print AST, exit |
--source-map | Emit source maps |
--check-lean | After Lean codegen, run lake build |
Examples
cambrian-transpiler contracts/counter.cam -o /tmp/c-evm --target evm
cambrian-transpiler contracts/counter.cam -o /tmp/c-lean --target lean --check-lean
cambrian-transpiler --project examples/governor/project.yaml
cambrian-transpiler contracts/counter.cam --dump-ast
Build the binary with cargo build --release in the cambrian-lang workspace; the executable is target/release/cambrian-transpiler.
project.yaml Reference
Multi-file projects are declared in YAML and loaded with --project.
name: my-dapp
target: evm # required: evm | lean
output_dir: build/
sources:
- Token.cam
- Vault.cam
- Vault.test.cam
library_paths:
- ${CAMBRIAN_STDLIB} # optional; env-expanded
- ../vendor/cam-libs
imports:
- math.cam # shared helpers, not entities
- token/erc20-core.cam
deterministic_addresses: true
Top-level keys
| Key | Type | Notes |
|---|---|---|
name | string | Optional project name |
target | string | Required — evm or lean |
output_dir | string | Default build/ |
sources | list of paths | 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. |
deterministic_addresses | bool | EVM CREATE2 / factory mode |
foundry | object | Foundry / solc settings |
fuzz | object | Default fuzz runs / seed |
invariant | object | Default invariant runs / depth |
lean | object | Lean emission knobs |
Shared files (library_paths / imports)
Leave both keys out if you do not need them; existing projects stay unchanged.
imports:loads shared declaration files the same 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.
foundry
| Key | Notes |
|---|---|
solc_version, evm_version | Compiler / EVM revision |
optimizer, optimizer_runs, via_ir | Optimizer |
fuzz_runs | Default Foundry fuzz runs |
profiles | Per-profile fuzz/invariant runs, depth, fail_on_revert |
remappings | Solc remappings |
fuzz / invariant
| Section | Typical keys |
|---|---|
fuzz | runs (default 256), seed, shrink, max_local_rejects |
invariant | runs, depth, fail_on_revert, seed, max_local_rejects |
lean
| Key | Notes |
|---|---|
numerics | overflow-wrap (default; legacy alias bitvec), overflow-panic, or nat |
proof_helpers | Helper emission |
intrinsics | opaque or executable |
See also
Codegen Mapping
How Cambrian constructs lower on the public targets.
Solidity-EVM (--target evm)
| Cambrian | Solidity |
|---|---|
entity E | Contract E (flat file under src/) |
| members | Storage variables (+ transforms inlined into route bodies) |
| routes | External / public functions |
view routes | view functions |
msg::sender / msg::value | msg.sender / msg.value (payable when value is read) |
~> dest typed send | External call |
deploy E(...) | Factory / CREATE2 when deterministic |
event / emit | Solidity event / emit |
error / throw Name | Custom errors / revert |
test / property / invariant | Foundry tests / fuzz / invariant handlers |
Lean-EVM (--target lean)
| Cambrian | Lean |
|---|---|
| entity state | Structure fields (plus ghost key lists for iterated maps when needed) |
| routes | Functions World → … (failing routes in an error monad / Except-style surface) |
| sends / deploys | World updates (call, occupancy, balances) |
property / test | Theorems in *Spec.lean |
invariant | Action / step / runTrace style definitions + checks |
Prefer reading a generated output tree for the exact file layout; lowering details evolve with each backend.
Grammar (Informal EBNF)
Informal sketch of the public surface. The authoritative grammar is
cambrian-transpiler/src/cambrian.lalrpop.
Program ::= TopLevel*
TopLevel ::= ImportFile
| UseDecl
| PureFn
| TypeAlias
| RecordDecl
| EnumDecl
| Entity
| ExternEntity
| EventDecl
| ErrorDecl
| LibraryDecl
| UsingDecl
| TestDecl
| PropertyDecl
| InvariantDecl
ImportFile ::= "import" StringLit
UseDecl ::= "use" Ident
Entity ::= "entity" Ident "{" EntityItem* "}"
ExternEntity ::= SolidityImport? "extern" "entity" Ident "{" ExternRoute* "}"
SolidityImport ::= "@solidity_import" "(" StringLit ")"
EntityItem ::= Const | Macro | RecordDecl | EnumDecl | EventDecl | ErrorDecl
| RoutesBlock | Member
RoutesBlock ::= "routes" "{" Route* "}"
Route ::= RouteKind Ident "(" Params ")" ReturnType? From? Where? "=>" Body
RouteKind ::= /* empty */ | "view" | "pure" | "init" | "private" | "accept"
ReceiveRoute ::= "accept" "receive" "(" ")" "=>" Body
FallbackRoute ::= "fallback" "(" ")" "=>" Body
Body ::= "[" Action* "]" | PhasedBody
Action ::= Send | Deploy | Emit | Throw | Return | Let | IfActions
| VarCall | CallRoute | ForActions | …
Send ::= Message? "~>" Expr SendOpts?
VarCall ::= "var" Ident "=" Message "~>" Expr
CallRoute ::= "call" Ident "(" Args ")"
ForActions ::= "for" Pattern "in" Iter "=>" "[" Action* "]"
Emit ::= "emit" Ident "(" Args ")"
Deploy ::= "deploy" Ident DeployArgs? SendOpts?
Member ::= Ident ":" Type "{" Transform* "}"
Transform ::= "in" Ident "(" Pats ")" "=>" Expr
| PhaseTag ":" Expr
Expr ::= … | "for" Pat "in" Iter "{" Expr "}" | "if" … | "match" …
PropertyDecl ::= "property" StringLit "(" Params ")" "for" Ident With? Ctx? "{" PropBody "}"
TestDecl ::= "test" StringLit "for" Ident ("skip" "from")? With? "{" TestStep* "}"
InvariantDecl ::= "invariant" StringLit "for" InvariantTarget Attr* "{" InvBody "}"
Phased routes, match arms, collection literals, and iterator chains follow
the forms shown in the Language Guide. TVM-only constructs (gosh::,
rescue / recover, …) are not part of the public documentation surface.
See also Keywords and Operator Precedence.
Validation Rules
The transpiler reports diagnostics with short codes. Full text lives in cambrian-lang docs/LANGUAGE.md and cambrian-transpiler/src/validate/. Below is a public-target highlights list (EVM / Lean / Container).
Families
| Prefix | Concern |
|---|---|
| V | Entity / route / member / phase / deploy / match |
| E | Domain EVM / Solidity-language compatibility |
| L | Lean language / Lean–EVM pair |
| T | test / property / fuzz |
| I | invariant |
| F | project.yaml |
| W | Warnings / lints |
Notable V codes
| Code | Summary |
|---|---|
| V27 / V28 | Route-level vs per-phase where vs var scoping |
| V29 | Forbidden actions inside action-level for |
| V30 / V31 | extern entity uniqueness / route uniqueness |
| V32 / V33 | deploy / from arity (V33 is Domain EVM) |
| V34 / V35 | Unknown / mistyped emit |
| V36 | More than three indexed event params (EVM) |
| V38 / V39 | Unknown / mistyped throw Name |
| V40 / V41 | receive / fallback shape and uniqueness (EVM) |
| V42 | return(value) without -> T |
| V43 | ^member outside transform bodies |
| V45 | Duplicate deploy collision risk |
| V46–V53 | Hex-address ambiguity, match exhaustiveness, if without else, bare stdlib (V49), type bridges (V50), unused let, record cycles, let some patterns |
Notable E codes (EVM / Solidity)
| Code | Summary |
|---|---|
| E07 / E17–E21 / E23 | Solidity lowering limits (iterators, maps, tuples, casts) |
| E09 | from → require(msg.sender == …) (warning) |
| E12 | evm:: off the EVM domain |
| E16 | Unknown namespace / address_of without deterministic mode / encode misuse |
| E22 | Typed send to unknown entity (declare extern entity) |
Notable L codes (Lean)
| Code | Summary |
|---|---|
| L5 / L6 | Vacuous throw / return expectations |
| L8 | Send dest not a static in-program address |
| L9 / L11 | Failing self-call without a fail surface |
| L10 / L12 | Warnings: sends inside invariant steps; TVM rescue/recover dropped on Lean |
| L13 / L14 | Missing crypto lowering / unknown types |
Notable T / I / F / W
| Code | Summary |
|---|---|
| T10–T12, T15–T23 | Property params, assume/bound placement, with vs ctx, instance bindings |
| I1–I6, I8–I16 | Actions, checks, multi-entity qualification, trace::* |
| F1 / F3 | Coverage requires parent enabled; engine name |
| F4 | Imported file (in-language import or yaml imports:) declares entity / test / fuzz / invariant |
| F5 | yaml imports: or a bare import "…" did not resolve against the first search directory or any library_paths root |
| W7 / W8 | Fuzz fallback types; unpinned forall in concrete tests |
Many legacy platform constructs produce E-family diagnostics on EVM; prefer writing portable Cambrian that stays within msg:: / sys:: / std:: / evm:: as documented in this book.
FAQ
What is Cambrian?
A language for stateful, message-based programs (entities, routes, and member transforms). One .cam source can target Solidity-EVM and Lean-EVM backends.
How do I run tests?
- EVM:
cambrian-transpiler … --target evmthenforge 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, deterministic addresses, Foundry/Lean knobs, shared source lists, and library search roots (library_paths / imports) use project.yaml (reference).
Why did the compiler reject min(a, b)?
Bare stdlib names are invalid (V49). Write std::math::min(a, b).
Is Cambrian open source?
No. The language, transpiler, and cambrian-lang workspace are developed in
proprietary mode and are not published for public download. This book is
the public language guide; access to the toolchain is arranged separately. If
you are interested in collaborating with the development team, contact
[TBD].
Where is the language specified?
This book is the public guide. The cambrian-lang repo also maintains docs/LANGUAGE.md, docs/STDLIB.md, and the parser grammar — when docs disagree with code, trust the transpiler and fix the docs.
Can I mix hand-written Solidity with Cambrian?
Yes, via extern entity (and optional @solidity_import) and Foundry remappings for dependencies. See Extern Entity and the governor / OZ integration examples in the lang repo.