Cross-contract calls¶
Three call kinds are exposed from Fourier — call_b, delegatecall_b, staticcall_b — plus a legacy one-word call. All return a uint: 1 for success, 0 for revert / VM error.
Calldata is bytes¶
Calldata for a cross-contract call is a bytes value built with pack_sel:
let cd: bytes = pack_sel(target_selector, arg1, arg2);
let ok: uint = call_b(target_addr, cd, /* value */ 0, /* gas */ 50000);
pack_sel(sel, a1, ..., aN) allocates [length:32][sel:32][a1:32]...[aN:32] in scratch memory and returns the pointer. The total bytes length is 1 + N * 32 — one selector byte plus 32 bytes per argument. When the callee's dispatcher reads CALLDATALOAD 0, the high byte is the selector; subsequent params come from offsets 1, 33, 65, ....
call_b¶
Standard CALL semantics:
- New frame with
address = addr,caller = self. - Callee operates on its own storage.
valueWAVE transferred from caller to callee before code runs.- Sub-call gets its own state snapshot; revert rolls back independently.
VM opcode: CALL (0x80). Stack order (vm/machine.py):
The codegen path stashes in_len and in_off + 32 in scratch slots, then pushes the seven words in the order CALL pops them.
delegatecall_b¶
DELEGATECALL semantics — execute target's code in caller's context:
addressstays as self (not target).- Storage writes hit self's storage slots.
callerandcall_valuecarry over from the calling frame.- No
valueparameter (forwarded as-is).
Use cases: proxy contracts, library-style callable code where the library should mutate the caller's state directly.
VM opcode: DELEGATECALL (0x83).
staticcall_b¶
Read-only call — any state-mutating op inside the callee reverts:
SSTORE→ VMError (no rollback refund).LOG→ VMError.- Nested
CALLwithvalue > 0→ VMError.
VM opcode: STATICCALL (0x84).
Return data¶
Whichever call kind you use, the callee's return data is copied into 32 bytes at RETURN_AT (0x40) of the caller's memory. To read it from Fourier, you'd need a bytes deref, which isn't a first-class op in v1 — instead, the convention is for callees to return a single uint (or bool or address) and for callers to inspect via a follow-up MLOAD that the language doesn't expose.
In practice, the success word is the only result Fourier-level code typically reads. If you need the callee's return value, write a small wrapper or use the call result as a precondition only:
let ok: uint = call_b(target, cd, 0, 50000);
require(ok == 1);
// callee's return word sits at memory 0x40 but Fourier has no MLOAD primitive
library Foo { ... } blocks¶
Declare a library's interface at the top of your .fou file alongside your contracts:
A library block is an ABI declaration only — no bytecode is emitted for it. The compiler uses the declared method order to assign selectors (add → 0x01, mul → 0x02) so call sites can resolve methods by name.
Calling a library method¶
Use Foo::method(addr, gas, args...):
contract MyContract {
storage math_lib: address @ 0;
pub fn set_lib(a: address) {
math_lib = a;
}
pub fn double_sum(a: uint, b: uint) -> uint {
let s: uint = Math::add(math_lib, 50000, a, b); // selector resolved at compile time
return Math::mul(math_lib, 50000, s, 2);
}
}
Argument positions:
| Position | Meaning |
|---|---|
| 1 | Library address (where the library bytecode is deployed) |
| 2 | Gas limit for the sub-call |
| 3..N | Arguments to the library method |
Under the hood, Foo::method(addr, gas, args...) rewrites to lib_call(addr, <resolved selector>, args..., gas) and inherits all of lib_call's semantics — DELEGATECALL, caller's storage, reverts on failure, returns the first 32-byte return word.
The library implementation itself is just a regular contract block (in the same or a different file). Its public function order must match the library declaration so the selectors line up.
Compile-time errors¶
unknown library 'X'— call site references a library that was not declared in the source.library 'X' has no method 'y'— method name doesn't match any entry in the library declaration.X::y call needs at least (lib_addr, gas); got N args— the two mandatory positional args are missing.
lib_call — library DELEGATECALL sugar¶
The high-level form of "call a library via DELEGATECALL". One builtin captures the four-line pack_sel + delegatecall_b + require(ok) + MLOAD(RETURN_AT) pattern that you would otherwise spell out by hand:
is equivalent to:
let cd: bytes = pack_sel(0x01, a, b);
let ok: uint = delegatecall_b(lib_addr, cd, 100_000);
require(ok == 1);
let result: uint = /* the 32-byte word at RETURN_AT — Fourier has no MLOAD primitive */;
with the important difference that lib_call actually surfaces the return word — there is no plain Fourier syntax to do this with the lower-level builtins.
Argument ordering by position:
| Position | Meaning |
|---|---|
| 1 | Library contract address |
| 2 | Method selector (1-byte literal, usually) |
| 3..N-1 | Method arguments |
| N (last) | Gas limit for the sub-call |
Failure semantics:
- If the delegatecall reverts (or any sub-call error),
lib_callreverts the caller with no return data. There is no success word to inspect. - The library executes against the caller's storage (standard DELEGATECALL behavior). Writes the library makes hit the caller's slots, not the library's.
Example: math library¶
contract Math {
pub fn add(a: uint, b: uint) -> uint { return a + b; }
pub fn mul(a: uint, b: uint) -> uint { return a * b; }
}
contract MyContract {
storage math_lib: address @ 0;
pub fn init(lib_addr: address) {
math_lib = lib_addr;
}
pub fn double_sum(a: uint, b: uint) -> uint {
let s: uint = lib_call(math_lib, 0x01, a, b, 50_000); // add
return lib_call(math_lib, 0x02, s, 2, 50_000); // mul by 2
}
}
Selectors 0x01 / 0x02 follow the standard selector assignment rule: first pub fn in declaration order gets 0x01.
One-word call¶
The legacy form. calldata_word is a 32-byte word that becomes the entire calldata — first byte is the selector, remaining 31 are ignored by the callee's dispatcher unless they comprise a single argument. Use this when the callee takes zero or one arg:
For multi-arg calls, use pack_sel + call_b.
Gas forwarding¶
The VM caps forwarded gas at 63/64 of remaining (EVM "1/64th rule"; see vm/machine.py):
Pass a generous gas budget; the cap ensures the caller always has gas left to recover from a failed sub-call.
Reentrancy¶
There is no built-in reentrancy guard. The ReentrancyGuard stdlib shows the standard mutex pattern:
Set the flag before the external call; unset after. Any reentry during the call hits the flag and reverts.
Call depth¶
The VM enforces a maximum call depth of MAX_CALL_DEPTH = 256. The 257th nested call raises CallDepthExceeded (hard error, full gas consumed).
PQC precompile calls¶
verify_sig(scheme_id, pk, msg, sig) internally builds the precompile calldata layout [pk_len:4][msg_len:4][sig_len:4][pk][msg][sig] and issues a STATICCALL to the precompile address. The return word (1 valid / 0 invalid) is loaded into RETURN_AT and surfaced as the builtin's expression value. See Expressions / PQC signature verify.
Examples¶
Forward a value transfer¶
let cd: bytes = pack_sel(0); // empty calldata (selector 0; will revert at callee
// if no fn matches — useful for plain EOA transfers)
let ok: uint = call_b(target, cd, value_to_send, 50000);
require(ok == 1);
Read a counter (staticcall)¶
// counter.get() has selector 0x01
let cd: bytes = pack_sel(0x01);
let ok: uint = staticcall_b(counter_addr, cd, 30000);
require(ok == 1);
// Counter's return word now sits at memory 0x40 but Fourier can't read it directly in v1.