Route Actions
Route actions define what happens when a route is invoked. They appear inside
the action list after the => arrow. Actions are the imperative side of an
entity — they send messages, deploy instances, bind intermediates, and produce
return values.
Action List
The action list is enclosed in square brackets. Actions follow one another
without separators; only let, var, and emit end with a semicolon:
pay(to: address) => [
let fee = msg::value / 100;
let net = msg::value - fee;
~> to with { value: net }
~> m_treasury with { value: fee }
]
An empty action list is written as []:
increment() => []
Action Types
| Action | Syntax | Chapter |
|---|---|---|
| Send message | route(args) ~> dest | Send Messages |
| Plain transfer | ~> dest with { value: amount } | Send Messages |
| Deploy | deploy Entity(args) | Deploy |
| Emit event | emit Event(args); | Emit Events |
| Conditional | if cond => [actions] else [actions] | Conditional Actions |
| Return | return(value) | Return Values |
| Call | call route(args) | Call |
| Let binding | let x = expr; | See below |
Let Bindings in Actions
The let keyword introduces a named intermediate value within the action list.
Let bindings are terminated by a semicolon:
forward() => [
let fee = msg::value * 3 / 1000;
let payout = msg::value - fee;
~> m_recipient with { value: payout }
~> m_fee_collector with { value: fee }
]
Let bindings are evaluated in order. Later bindings and actions can reference earlier bindings.
Execution Order
An unphased route runs in this order:
fromchecks, thenwherechecks — any failure reverts the whole route.- Every member transform is computed (from the values the members had before the route) and the results are stored.
- Actions execute in declaration order:
letbindings are evaluated, sends, deploys, and emits happen, conditionals branch on their guard.
The combination of actions and member transforms defines the complete effect of a route invocation. On EVM this is the checks-effects-interactions order: storage is written before any external call is made.
Actions and State
State mutations happen through member transforms, not through actions. This separation is a core design principle of Cambrian. Transforms are stored before the actions run, but which member value an action reads depends on the action:
- Arguments of outbound effects — sends (
~>, includingvarcaptures),call, anddeploy— read the value from before the route’s transforms. - Everything else —
letbindings,emitarguments,ifconditions, andreturn— reads the new value.
entity Observer {
routes {
notify(count: u64) => []
}
m_last: u64 {
in notify(count) => count
}
}
entity Counter {
event Counted(count: u64);
routes {
#[factory_only]
init setup(observer: Address<Observer>) => []
increment_and_notify() => [
// send argument: the value *before* the increment
notify(m_count) ~> m_observer
// emit argument: the value *after* the increment
emit Counted(m_count);
]
}
m_observer: Address<Observer> {
in setup(observer) => observer
}
m_count: u64 {
in increment_and_notify() => m_count + 1
}
}
This lets a route pay out a balance that its own transforms reset to zero, while still writing storage before the transfer:
claim() => [
~> msg::sender with { value: m_pending[msg::sender] }
]
with in claim() => m_pending.insert(msg::sender, 0). Write the member read
directly in the send: let amount = m_pending[msg::sender]; would already
read the reset value (zero). To carry an old value through a let, bind it in
an earlier phase.
Complete Example
extern entity Token {
route transfer(to: address, amount: U256);
}
entity Exchange {
error SlippageExceeded();
routes {
#[factory_only]
init setup(token: Address<Token>, rate: U256) => []
buy(min_out: U256)
where msg::value > 0 : throw 100
=> [
let amount_out = msg::value * m_rate / 1_000_000;
if amount_out < min_out => [
throw SlippageExceeded()
] else [
transfer(msg::sender, amount_out) ~> m_token
]
]
}
m_token: Address<Token> {
in setup(token, _) => token
}
m_rate: U256 {
in setup(_, rate) => rate
}
}