Queries and deferred calls
Lithe splits external interaction into two constructs that cannot be confused:
query | defer | |
|---|---|---|
| Runs | now, in the middle of the transition | after the transition commits |
| Compiles to | STATICCALL | CALL |
| Can change state | no, and the EVM enforces it | yes |
| Result | an ordinary value | available in a continuation |
| Allowed in | view and stateful functions | stateful functions |
Interfaces
An interface describes a contract's external functions. Each method is a function header with no body: view or pure methods are read with query, and the others are called with defer:
interface IERC20 is {
view function balanceOf(owner : address) : uint;
view function allowance(owner : address; spender : address) : uint;
function transfer(to : address; amount : uint) : bool;
function transferFrom(from : address; to : address; amount : uint) : bool;
}Method names and parameter types form the ABI signature exactly as written: transferFrom(address,address,uint256). Parameters are name : type. A method with no result leaves it out, and a method may list the errors it raises with raises. Arguments and results can be strings, bytes, and lists as well as words. A method can be overloaded; a call picks the overload whose parameters its arguments match.
A method's keywords are its ABI stateMutability, and say how it can be called:
| Method | ABI | Called with |
|---|---|---|
view function | view | query |
pure function | pure | query |
function | nonpayable | defer |
payable function | payable | defer, with or without with value |
Declare a method payable when the contract it describes accepts ether with the call, as its own ABI says.
A method's arguments and result can be any ABI value: words, strings, bytes, lists, tuples, and records, nested to any depth. Results are decoded and checked where they arrive, by a query or in a continuation, with Solidity's bounds on every offset and length.
Records follow the same rule as external functions: a record argument or result is its fields, and a tuple record is one tuple. A method that takes a Solidity struct, like Uniswap's exactInputSingle, takes a tuple record. A tuple result is one Solidity tuple, as a function returns (S memory) gives. For a method with several results, returns (uint112, uint112, uint32), declare a record, whose fields are its results in order:
type Reserves is record {
reserve0 : uint112;
reserve1 : uint112;
last : uint32;
}
interface Pair is {
view function getReserves() : Reserves;
}
storage is record {
pair : Pair;
}
external view function price() : uint is {
const r : Reserves := query storage.pair.getReserves();
} with uint(r.reserve1) * 1_000_000 / uint(r.reserve0)For a result of words only, like this one, a tuple works too, since both lay out the same words: view function getReserves() : (uint112, uint112, uint32);, destructured with const (a, b, t) : (uint112, uint112, uint32) := query p.getReserves();.
Optional results
Some contracts return nothing where their interface promises a value: older tokens return nothing from transfer. Declare such a result option(T). No return data from an account with code is None, and anything else is decoded as T, checked as any result is, and is Some:
interface Token is {
function transfer(to : address; value : uint) : option(bool);
}
error TransferFailed;
storage is record {
token : Token;
}
external nonreentrant function pay(const to : address; const value : uint)
raises TransferFailed
is {
defer storage.token.transfer(to, value) then (ok : option(bool)) {
assert(ok <> Some(false), TransferFailed);
};
}This is OpenZeppelin's SafeERC20 and Solady's SafeTransferLib: (bool ok, bytes memory data) = token.call(...) followed by data.length == 0 ? token.code.length > 0 : abi.decode(data, (bool)). No data from an account with no code reverts, as a too-short result does, even under an Error arm, so a transfer of a token that doesn't exist fails. T is a static value, and a queried one is a single word. An option is not an ABI value, so an optional result can't be one a contract exposes.
The same interfaces say what a contract exposes from a module: use M exposing I declares every method of I. See Modules and contracts. And supports I announces I through ERC-165, with the XOR of its methods' selectors as its id. See ERC-165.
A value of an interface type is a contract address. It is an address in the ABI and in storage. Convert between the two with IERC20(addr) and address(token). Interface values usually arrive as parameters and are kept in storage:
storage is record {
token : IERC20;
}
external function init(const token : IERC20) is {
storage.token := token;
}Interfaces can be declared at the top level, or inside a contract or module.
query
query target.method(args) is an expression. It runs the call immediately through STATICCALL, decodes the result, and continues:
interface Oracle is {
view function price(asset : address) : uint;
}
error PriceTooLow of uint;
storage is record {
oracle : Oracle;
floor : uint;
}
external view function price(const asset : address) : uint is
query storage.oracle.price(asset)
external function check(const asset : address)
raises PriceTooLow
is {
const p : uint := query storage.oracle.price(asset);
assert(p >= storage.floor, PriceTooLow(p));
storage.floor := p;
}STATICCALL means the callee, and everything it calls, cannot modify state, emit logs, or send ether. If it tries, or reverts, the transaction reverts, unless you match the query's outcome with case query ... of { Ok(v) -> ... | Error(e) -> ... }. See Recovering from failed calls.
You cannot query a stateful method, or defer a view one:
interface X is {
function f() : uint;
}
storage is record {
x : X;
n : uint;
}
external function a() is {
storage.n := query storage.x.f();
}query expects a read-only methoddefer
defer target.method(args); records a state-changing call. It runs after the transition's storage writes and events, in the order the defers ran:
interface Token is {
function transfer(to : address; amount : uint) : bool;
}
storage is record {
token : Token;
paid : map(address, uint);
}
external nonreentrant function pay(const to : address; const amount : uint) is {
storage.paid[to] := storage.paid[to] + amount;
defer storage.token.transfer(to, amount);
}The target, arguments, value, and gas limit are evaluated at the defer, and the call itself happens later. A defer inside an if runs only if that branch ran.
Deferred calls forward all remaining gas, unless they set a gas limit. If a deferred call reverts, the whole transaction reverts, unless its continuation has Ok and Error arms. When a continuation binds the call's result, the result is decoded and checked first, and a malformed result reverts.
A call to an account with no code succeeds on the EVM, so as in Solidity it is checked. A method without a result reverts before the call when the account has no code, and a result nothing decodes must still be as long as its head words. An optional result may be missing only when the account has code: one nothing decodes reverts before the call when there is none, as a method without a result does, and a decoded one checks for code only when no data came back.
Sending ether with a call
Attach ether with with value = amount. The amount is a uint number of wei. Only a method its interface declares payable function takes value:
import "std/evm";
interface Pool is {
payable function deposit(beneficiary : address);
}
storage is record {
pool : Pool;
}
external payable nonreentrant function forward() is {
defer storage.pool.deposit(Evm.sender()) with value = uint(Evm.value());
}Sending value to any other method is an error, because the callee would reject it:
import "std/evm";
interface Pool is {
function deposit(beneficiary : address);
}
storage is record {
pool : Pool;
}
external payable function forward() is {
defer storage.pool.deposit(Evm.sender()) with value = uint(Evm.value());
}`deposit` is not payable; declare it `payable function deposit` in its interface to send valueLimiting a call's gas
A call gets all the gas left unless it says otherwise. with gas = g caps it at g, a uint, as Solidity's {gas: g} does. It works on a query, a defer, and the raw calls and Evm.transfer, and combines with value in braces: with { value = v; gas = g }. ERC-165 detection, for example, queries supportsInterface with 30,000 gas and treats a failure as no:
interface IERC165 is {
view function supportsInterface(interfaceId : bytes4) : bool;
}
storage is record {
receiver : bool;
}
external function check(const target : IERC165) is {
storage.receiver := case query target.supportsInterface(0x150b7a02) with gas = 30000 of {
| Ok(yes) -> yes
| Error(_) -> false
};
}A call that runs out of its gas fails like any other: the transaction reverts, unless an Error arm recovers. create takes no gas limit, since the EVM's creation always gets all the gas left.
Evm.transfer
defer Evm.transfer(to, amount) sends plain ether. to is an address or an interface value, and amount is wei:
import "std/evm";
error Insufficient;
storage is record {
balances : map(address, wei);
}
external nonreentrant function withdraw(const amount : wei)
raises Insufficient
is {
const have : wei := storage.balances[Evm.sender()];
assert(have >= amount, Insufficient);
storage.balances[Evm.sender()] := have - amount;
defer Evm.transfer(Evm.sender(), amount);
}The transfer forwards all remaining gas, like Solidity's call{value: ...}(""), unless it sets a gas limit, and reverts the transaction if the recipient rejects it. It is always deferred.
create
defer create Name() deploys another contract defined in the same file, or in a file it imports. A continuation receives the new contract's address:
contract Factory is {
storage is record {
last : address;
}
external function open() is {
defer create Vault() then (vault : address) {
storage.last := vault;
};
}
}
contract Vault is {
storage is record {
owner : address;
}
external view function owner() : address is
storage.owner
}The created contract's code is embedded in the factory's bytecode. A file with several contracts builds one bytecode per contract, so compile it as a directory with lithe compile directory.
Contracts from other files
A file that imports a file of contracts can create them. The imported contracts stay that file's own, each built from it, and lithe compile directory writes each once:
import "std/evm";
import "./vault";
contract Factory is {
storage is record {
vaults : map(address, address);
}
external function open() is {
defer create Vault(Evm.sender()) then (vault : address) {
storage.vaults[Evm.sender()] := vault;
};
}
}contract Vault is {
storage is record {
owner : address;
}
deploy(const owner : address) is {
storage.owner := owner;
}
external view function owner() : address is
storage.owner
}Passing arguments
Arguments after the contract's name go to its deploy, which runs as the contract is created. They are checked against deploy's parameters, and the continuation runs after deploy has finished:
import "std/evm";
contract Factory is {
storage is record {
vaults : map(address, address);
}
external function open(const cap : uint) is {
defer create Vault(Evm.sender(), cap) then (vault : address) {
storage.vaults[Evm.sender()] := vault;
};
}
}
contract Vault is {
storage is record {
owner : address;
cap : uint;
}
deploy(const owner : address; const cap : uint) is {
storage.owner := owner;
storage.cap := cap;
}
external view function owner() : address is
storage.owner
}The arguments are ABI-encoded and appended to the creation code, as Solidity does for new Vault(owner, cap). A contract without deploy takes no arguments:
import "std/evm";
contract Factory is {
storage is record {
last : address;
}
external function open() is {
defer create Vault(Evm.sender());
}
}
contract Vault is {
storage is record {
owner : address;
}
external view function owner() : address is
storage.owner
}`Vault` has no `deploy` parameters, so it takes no argumentsValue and salt
with value = amount sends ether to the new contract. The amount is a uint number of wei, as for calls. A contract whose deploy is not payable rejects the value, and the creation fails.
with salt = s takes a bytes32 and deploys with CREATE2. The address then depends only on the factory, the salt, and the created contract's creation code, arguments included, so it can be computed before deployment. Without a salt, create uses CREATE, and the address depends on the factory's nonce.
Evm.creationCode(Name) is the creation code create runs, before the arguments it appends. Its hash is a constant, so a factory knows where a CREATE2 lands before the contract exists, and can return the address from the transition that creates it:
import "std/crypto";
import "std/evm";
import "std/utils/create2";
contract Factory is {
use Create2;
storage is record {
count : uint;
}
external function open(const salt : bytes32) : address is {
defer create Vault() with salt = salt;
storage.count := storage.count + 1;
} with Create2.computeAddressHere(salt, Crypto.keccak256(Evm.creationCode(Vault)))
}
contract Vault is {
storage is record {
owner : address;
}
external view function owner() : address is
storage.owner
}To give both options, put them in braces:
import "std/evm";
contract Factory is {
storage is record {
last : address;
}
external payable function openFunded() is {
defer create Vault() with value = uint(Evm.value());
}
external function openAt(const salt : bytes32) is {
defer create Vault() with salt = salt then (vault : address) {
storage.last := vault;
};
}
external payable function openFundedAt(const salt : bytes32) is {
defer create Vault() with { value = uint(Evm.value()); salt = salt; } then (vault : address) {
storage.last := vault;
};
}
}
contract Vault is {
storage is record {
owner : address;
}
external view function owner() : address is
storage.owner
}When creation fails
A creation fails if the factory cannot pay the value, if the salted address is already taken, if the created contract's deploy reverts, or if its code does not fit. A failed creation reverts the transaction, unless the continuation has Ok and Error arms:
contract Factory is {
storage is record {
last : address;
failed : uint;
}
external function openAt(const salt : bytes32) is {
defer create Vault() with salt = salt then {
| Ok(vault : address) -> {
storage.last := vault;
}
| Error(_) -> {
storage.failed := storage.failed + 1;
}
};
}
}
contract Vault is {
storage is record {
owner : address;
}
external view function owner() : address is
storage.owner
}Creation code as bytes
defer create(code) runs a bytes value as creation code, for contracts that are not Lithe contracts of this build: ERC-1167 clones, CREATE3 proxies, and SSTORE2 data. It takes with value and with salt and fails as create Name() does, and its continuation gets the new address. Evm.code(a) reads an account's code back:
import "std/bytes";
import "std/evm";
storage is record {
last : address;
}
(* ERC-1167: a clone that delegates every call to `target`. *)
external function clone(const target : address; const salt : bytes32) is {
defer create(Bytes.concat(0x3d602d80600a3d3981f3363d3d373d3d3d363d73, bytes20(target), 0x5af43d82803e903d91602b57fd5bf3))
with salt = salt then (made : address) {
storage.last := made;
};
}
(* SSTORE2: `data` becomes the code of a contract that stops at once. *)
external function write(const data : bytes) is {
defer create(Bytes.concat(0x61, bytes2(uint16(data.length + 1)), 0x80600a3d393df300, data)) then (made : address) {
storage.last := made;
};
}
external view function read(const pointer : address) : bytes is
Evm.code(pointer)[1:]The standard library's Clones, SSTORE2, and Create3 build this code, and predict where it lands. A slice of Evm.code(a) copies only the bytes it takes.
Raw calls
When the calldata is only known at runtime, as it is for a wallet, a timelock, or a batch of calls, send it as bytes. Raw calls follow the same split as interface methods: the one that cannot change state is a query, and the others are deferred.
| Call | Compiles to | Result |
|---|---|---|
query Evm.staticCall(to, data) | STATICCALL | the return data, as bytes |
defer Evm.call(to, data) | CALL, with optional with value = amount | the return data, to a continuation |
defer Evm.delegateCall(to, data) | DELEGATECALL | the return data, to a continuation |
to is an address or an interface value, and data is the whole calldata, selector included. Build it with Abi.encodeCall, Abi.encodeWithSelector, or take it as a parameter. Nothing decodes the answer: a continuation's bytes are the return data exactly as it came back, and Abi.decode reads them.
import "std/evm";
error NotOwner;
storage is record {
owner : address;
nonce : uint;
}
external nonreentrant function execute(const target : address; const value : uint; const data : bytes)
raises NotOwner
is {
assert(Evm.sender() = storage.owner, NotOwner);
storage.nonce := storage.nonce + 1;
defer Evm.call(target, data) with value = value;
}A raw call fails and recovers as any other does. Without Ok and Error arms, a failed call reverts the transaction; with them, Error(reason : bytes) gets the revert data, and forward reason passes it on:
import "std/evm";
storage is record {
failures : uint;
}
external nonreentrant function tryCall(const target : address; const data : bytes) is {
defer Evm.call(target, data) then {
| Ok(result : bytes) -> { skip }
| Error(reason : bytes) -> {
storage.failures := storage.failures + 1;
}
};
}Evm.callto an account with no code succeeds, as the EVM'sCALLdoes: an ether transfer with data to a wallet is a call like any other.Evm.delegateCallto an account with no code reverts, because it would run nothing and report success.Evm.delegateCallruns the target's code as this contract, on its storage, after the transition's own writes have landed. The continuation reads storage again, so it sees what the code changed. That code can write any slot, so delegate only to code you trust. It takes nowith value: the code sees the value of the call that deferred it.Evm.staticCallreaches precompiles, which have no ABI of their own.query Evm.staticCall(0x0000000000000000000000000000000000000002, data)is SHA-256 ofdata.lithe footprintlists raw calls asEvm.call,Evm.delegateCall, andEvm.staticCall.
A pure function cannot query, and a view cannot defer, for raw calls as for any other:
import "std/evm";
storage is record {
n : uint;
}
external view function peek(const to : address; const data : bytes) : bytes is
Evm.staticCall(to, data)`Evm.staticCall` is read-only; write `query Evm.staticCall(...)`What cannot be written
- An inline state-changing call.
token.transfer(...)withoutdeferis not an expression, and neither isEvm.call(...). - A
queryin apurefunction, or adeferin aview. - A
queryordeferin a migration.

