Contract size limit
EIP-170 caps deployed code at 24,576 bytes. Lithe never moves code out of a contract on its own. You choose what runs elsewhere, by marking a module delegated where the contract uses it. A marked module runs from its facet, a contract of its own that is built from the module alone and shared by every contract that marks it. The contract that uses it becomes a router. Callers still see one contract: one address, one ABI, and the same return data, logs, and errors.
A contract that marks nothing compiles as it always does, byte for byte. When it is over the limit it still compiles, with a warning that says what takes the space and what marking each module would save. Only a contract over 65,535 bytes, which can't be assembled at all, is an error.
Marking a module
delegated goes right after the module's name in a use, before exposing and the interfaces it exposes:
module Ledger is {
storage ledger at "example.ledger" is record {
balances : map(address, uint);
total : uint;
}
error Short of uint;
event Minted of record {
indexed to : address;
amount : uint;
}
pure function fee(const amount : uint) : uint is
amount * 30 / 10_000
view function balance(const who : address) : uint is
ledger.balances[who]
function mint(const to : address; const amount : uint) is {
ledger.balances[to] := ledger.balances[to] + amount;
ledger.total := ledger.total + amount;
emit Minted { to = to; amount = amount; };
}
function burn(const from : address; const amount : uint)
raises Short
is {
const have : uint := ledger.balances[from];
assert(have >= amount, Short(have));
ledger.balances[from] := have - amount;
ledger.total := ledger.total - amount;
}
external view function total() : uint is
ledger.total
}import "std/evm";
import "./lib/ledger";
contract Bank is {
use Ledger delegated exposing {
view function total() : uint;
}
storage is record {
deposits : uint;
}
external payable function deposit() is {
const amount : uint := uint(Evm.value());
Ledger.mint(Evm.sender(), amount - Ledger.fee(amount));
storage.deposits := storage.deposits + 1;
}
external function burn(const amount : uint)
raises Short
is {
Ledger.burn(Evm.sender(), amount);
}
external view function balance() : uint is
Ledger.balance(Evm.sender())
}A module the contract uses without exposing anything is marked the same way: use Ledger delegated;. An unmarked module is always part of the contract.
A delegated module and a fallback that delegates to both run code that lives elsewhere as this contract, on its storage. The difference is where that code comes from: a delegated module's facet is built by the compiler from the module's own source, at an address derived from that code, and is reachable only through the router, while delegates to goes wherever its target expression says.
What runs where
delegated means the module's code never lives in the contract. Everything the module does, the contract reaches in its facet:
- Its externals. For each one the contract exposes, the router forwards the call to the facet with
delegatecall, so the facet runs on the router's storage, balance, and address. A guarded external runs the contract's check in the router first. - Its hooks are the contract's code: the facet calls them back into the router. See Hooks.
- Its functions, called from the contract's own code. An entrypoint, a view,
deploy, a migration, a guard, or a continuation that calls one of the module's functions calls it across, in the facet. Apurefunction is called withstaticcall. Aviewor stateful one is called withdelegatecall, so it reads and writes the contract's storage, and its events are the contract's. Aviewentrypoint can call a view function across:delegatecallis allowed inside astaticcall, and the function writes nothing.
In the example, deposit calls Ledger.fee with staticcall and Ledger.mint with delegatecall, and balance calls Ledger.balance with delegatecall too. Each call ABI-encodes its arguments, the facet runs the function, and the result is decoded where the function's result would have been. When the function fails, the contract reverts with the facet's revert data, so its errors and panics reach the caller unchanged. What a function does is the same as it would be inline: the same results, errors, events in the same order, and storage.
The module's constants, pure functions with no parameters, are values, and fold into the contract as they always do. A function that writes the module's immutables runs in the contract too: only deploy and migrations can call it, and they are where the contract's immutables are written.
A module's externals are still not functions: nothing inside the contract calls them, delegated or not.
What can be called across
A function can be called across when its parameters and result are ABI values, the values an external can take and return. These can't:
- a generic function
- a function with a
storageparameter, or a parameter or result that is anoption, a map, or a variant with payloads - a
viewor stateful function that reads the call's calldata withEvm.calldata()orEvm.selector(), since its facet sees its own
A function that defers, or reaches a hook that does, can be called across: its calls wait for the contract's transition, as deferred calls across frames describes. A call to one that can't is an error that names the function and the reason:
import "std/evm";
module Book is {
storage book at "example.book" is record {
pages : map(uint, uint);
}
function write(storage pages : map(uint, uint); const k : uint) is {
pages[k] := 1;
}
}
contract Shelf is {
use Book delegated;
storage is record {
mine : map(uint, uint);
}
external function note(const k : uint) is {
Book.write(storage.mine, k);
}
}`Book.write` runs in `Book`'s facet, and contract code can't call it across: its parameter `pages` is a storage reference; move the call into `Book`, or use `Book` without `delegated`A view or stateful function in the facet sees storage as it is when it is called. A body that runs a query after writing storage keeps its writes until it ends, so that the query sees storage as the transition found it. Such a body can't call a view or stateful function across, since the function wouldn't see the writes:
module Book is {
storage book at "example.book" is record {
pages : map(uint, uint);
}
function note(const k : uint; const v : uint) is {
book.pages[k] := v;
}
}
interface Oracle is {
view function price() : uint;
}
contract Shelf is {
use Book delegated;
storage is record {
n : uint;
}
external function note(const oracle : Oracle) is {
storage.n := storage.n + 1;
Book.note(storage.n, query oracle.price());
}
}`Book.note` runs in `Book`'s facet, which sees storage as it is when called, but a body that calls it runs a query after writing storage, so it keeps its writes until it ends; query before the first write, or use `Book` without `delegated`Deferred calls across frames
A function called across and a hook called back each run in a frame of their own, in the middle of another frame's transition: the external the transaction called, or one of its continuations. Their deferred calls belong to that transition, its owner, so they can't run when their frame ends. Every one of these frames runs at the contract's address, so they share its transient storage, and that is where the calls wait:
- Queueing. A frame that deferred a call saves its memory, which holds the calls and everything their continuations read, as a record in transient storage, and names the record for its caller. The caller notes the record among its own deferred calls, where the call across stands.
- Draining. Once the owner's body has committed, it runs its deferred calls in order. At a record, it makes a
delegatecallinto the code that queued it, through one more hidden entrypoint: that code puts its memory back and runs the calls and their continuations as its own epilogue would have. A hook's record goes back into the contract's code, through the same flag as the hook's way back in. A function's record goes back into its facet, with the immutables and the hook word its calls always carry. A record holds the records of the frames nested in it, and drains them the same way. - The same as inline. Calls run in the order the transition reached them, a hook reached twice queues twice, each continuation sees its own bindings, and failures,
Errorarms, andforwardbehave as they do inline: what a drained call reverts with, the transition reverts with. The lock is held while they run. Draining clears a record, so nothing is left once the transition ends, and two transitions in one transaction never see each other's calls. - Calldata. A frame that may queue calls decodes its arguments into memory, since the frame that drains them has other calldata.
The facet's own entrypoints
A facet has an entrypoint for each function of its module that contract code can call across, whether or not a given contract calls it. A module's private functions are only for its own code, so they have none, which keeps a facet's helpers from costing it bytes. Their names can't be written in source and no ABI lists them, so no external of the module can collide with one. The router never dispatches their selectors: it forwards only the externals the contract exposes, and a call with any other selector reaches the contract's own code, or reverts. Called directly, a facet runs against its own storage, not the storage of any contract that uses it, so there is nothing there to protect. The same argument covers guards: a facet runs its module's externals unguarded, which is what lets contracts with different guards share it.
Sharing
A facet is built from its module alone: the module's externals, an entrypoint for each function contract code can call across, and copies of the modules it requires. None of the contract is in it, so it doesn't matter which contract marks the module, which of its externals the contract exposes, or which of its functions the contract calls. Every contract that marks the same module gets the same facet at the same address, so ten collections built on the same modules deploy each facet once. A facet changes, and moves to a new address, only when its module or a module it requires changes.
Two facets never call each other. A module that requires another carries a copy of it in its facet, whether or not the contract marks that one too, and calls it there. Storage is shared all the same: each module's storage sits at its own ERC-7201 root, the same in the router and in every facet, and a module's transient storage sits at a slot derived from its name.
Code the contract and a facet both run
delegated holds for the whole module, always: none of a marked module's code is in the contract, and every call the contract's code makes to it goes across. To share code between the contract and a facet, put it in a module of its own that neither marks. The contract uses it unmarked, so it runs in the router. The delegated module requires it, so its facet carries a copy and calls that.
import "std/evm";
module RoleStore is {
storage roles at "example.roles" is record {
minters : map(address, bool);
}
error Unauthorized;
view function onlyMinter() raises Unauthorized is {
assert(roles.minters[Evm.sender()], Unauthorized);
}
function grant(const account : address) is {
roles.minters[account] := true;
}
}import "std/address";
import "./role_store";
module Drop is {
requires RoleStore;
storage drop at "example.drop" is record {
owners : map(uint, address);
}
error AlreadyMinted of uint;
external function mint(const to : address; const id : uint) raises Unauthorized | AlreadyMinted is {
RoleStore.onlyMinter();
assert(drop.owners[id] = Address.zero, AlreadyMinted(id));
drop.owners[id] := to;
}
}import "./lib/role_store";
import "./lib/drop";
contract Shop is {
use RoleStore;
use Drop delegated exposing {
function mint(to : address; id : uint) raises Unauthorized | AlreadyMinted;
}
storage is record {
paused : bool;
}
deploy(const minter : address) is {
RoleStore.grant(minter);
}
external function pause() raises Unauthorized is {
RoleStore.onlyMinter();
storage.paused := true;
}
}deploy and pause run RoleStore in the router, and mint runs the facet's copy of it, against the same storage. Marking RoleStore too would put its code in a facet of its own, and pause would then call onlyMinter across, paying a delegatecall each time. So the split follows where the code runs. Code that runs in a facet, and that contract code calls only through externals, goes in the delegated module. Code the contract calls itself goes in a module it uses unmarked.
Over the limit
A contract over --size-limit still compiles and emits its bytecode, with a warning. It lists what takes the space: the contract's own entrypoints, by group, and for each module the contract runs in its own code, what marking it delegated would save. Here the standard-library token, with no modules marked, is built with --size-limit 2000:
token.lithe:16:1: warning: contract `Token` is 2,894 bytes; the limit is 2,000
ERC20 758
Token 621
ERC20Permit 526
Ownable 89
ERC20Burnable 83
Pausable 1
(dispatch and shared code) 816
each module's row is what the contract saves when the module runs from its own facet contract instead: mark it with `use M delegated;` or `use M delegated exposing ...;`. The contract's own row is its entrypoints, which stay in itA module that can't be a facet says why instead of a number, such as can't be a facet: it is generic, or which of its functions the contract calls that can't be called across. A facet over the limit gets a warning of its own. Creation code has a limit of twice --size-limit, as EIP-3860 allows, and a contract or a facet over that is warned about the same way.
Before any of that, the compiler fits a contract by outlining: code the contract repeats becomes shared subroutines, as few as it takes to fit. That changes code, not what runs where, so it never needs a mark.
Immutables
A facet's code is shared, so it can't have one contract's immutables built in. It reads them from its router instead. For each call it forwards, and each call across, the router appends the values of every immutable block the facet has, its module's and those of the modules it requires, after the calldata, blocks in order of their ids. A plain router keeps them in its own code, where its creation code writes them. An upgradeable router loads them from the proxy's storage. A forwarded call whose code reads no immutable gets words of the same length without the values, so it pays for neither. The facet reads each from the end of its calldata, and everything in it that reads the calldata or its size, such as the ABI decoder or Evm.calldata(), sees the calldata without them. What a facet reads is fixed by its module alone, so its address still depends only on its code.
Hooks
A delegated module's hooks are the contract's code, and the facet is built from the module alone, so the facet calls each hook back into the contract:
- The call. The facet makes a
delegatecallinto the contract's own code, to a hidden entrypoint for the hook. Its name can't be written in source and no ABI lists it.delegatecall, not a call to itself, so the hook runs on the contract's storage, with the call's sender and value:Evm.sender()in a hook is the caller of the external, as it is inline. A plain contract's facet calls its router's own address. An upgradeable one calls its implementation, whose address the router passes along, so the hook runs on the proxy's storage. - Who may call it. Just before calling a stateful hook back, the facet sets a flag in transient storage, which only code running as this contract can set. The hidden entrypoint runs only when the flag is set, and clears it first, so nothing the hook calls can come back in through it, and a hook whose body calls into the facet again, which calls another hook back, gets a flag of its own. A
vieworpurehook's entrypoint has no flag, since views are often called withstaticcall, where nothing can write one; it only reads, so whoever calls it learns no more than the contract's state already says. - Which hooks the contract implements. After its immutables, the router appends one more word to the calldata of each call it forwards to the facet, and of each call across: the address hooks are called back at, kept as its difference from the contract's own address so that a plain contract's word is a constant, and a bit for each of the facet's hooks the contract implements. The facet tests the bit before it calls back, so a hook the contract skips, or that its code doesn't reach, costs a bit test. A hook with a result has a bit only when the contract implements it, and the facet reverts without one, which only a call that skips the router can see. A facet has bits only for the hooks its own code reaches: the facet of a module that only requires one with hooks has none, and calls to it carry no extra word.
- Sharing. None of this depends on the contract: two contracts that implement a module's hooks differently share its facet.
- Lock. The hidden entrypoint doesn't take the
nonreentrantlock: it runs inside the transition that reached the hook.
A hook called back runs in a frame of its own. A hook that defers works there as it does inline, as deferred calls across frames describes, but two things a hook may do inline it can't do there:
- Read the call's calldata, with
Evm.calldata()orEvm.selector(): the hook would see the facet's call to it. - Run during
deployof a contract that isn't upgradeable, which has no code untildeployends.
Each is an error that names the hook. A body in the facet that runs a query after writing storage can't call a hook back either, as it can't call a function across.
import "std/evm";
module Tally is {
storage tally at "example.tally" is record {
count : uint;
}
hook counted(const count : uint);
external function add() is {
tally.count := tally.count + 1;
counted(tally.count);
}
}
contract Counter is {
storage is record {
last : uint;
}
use Tally delegated exposing {
function add();
}
hook Tally.counted(const count : uint) is {
storage.last := Evm.calldata().length;
}
}hook `Tally.counted` is called back from `Tally`'s facet, and it can't be: it reads the call's calldata, and a hook called back sees the facet's call to it, or use `Tally` without `delegated`lithe facets lists the hooks each facet calls back, lithe abi-tree and lithe footprint show each entrypoint's hooks and where each runs, and a hidden entrypoint the facets never call back isn't in the router.
Seeing what runs where
lithe facets shows the router and each facet, with the size of each against the limit:
lithe facets token.lithe --size-limit 2000router 1,630 / 2,000
Token: transfer, transferFrom, mint, pause, unpause, supportsInterface
ERC20Burnable: burn, burnFrom
Ownable: transferOwnership, renounceOwnership, owner
Pausable: paused
facet ERC20 0x13F9822a430D5E4E70Af364A11350407Be3bBD81 2,217 / 2,000
approve, name, symbol, decimals, totalSupply, balanceOf, allowance
called across: ERC20.init, ERC20.totalSupply, ERC20.move, ERC20.mint, ERC20.burn, ERC20.spendAllowance
(2 more externals this contract does not declare)
facet ERC20Permit 0x04d7e8AfDE9737f054132c9eEAD1E05d275Fd0DA 739 / 2,000
permit, nonces, DOMAIN_SEPARATORThat is the same token with delegated on its ERC20 and ERC20Permit uses. Under each facet are the externals the router forwards to it, and then the module's functions the contract's code calls across. A facet holds every external its module declares; the router forwards only the ones the contract declares, so the others can't be reached through it. lithe footprint and lithe abi-tree also show where each entrypoint runs.
Deploying
Each facet is deployed with CREATE2 through the deterministic deployment proxy at 0x4e59b44847b379578588920cA78FbF26c0B4956C, the same factory Foundry uses, with a zero salt. So a facet's address depends only on its code. The compiler knows every address in advance and builds them into the router. The router can only ever reach the exact code it was compiled against. Contract code reads a facet's address as a constant with Evm.facetAddress(M), for a view that reports it, or a registry that lists it.
For a contract with facets, lithe compile prints the deployment plan instead of bytecode, with a note on stderr:
note: `Token` runs `ERC20`, `ERC20Permit` from their facets{
"facets": [
{
"name": "ERC20",
"address": "0x13F9822a430D5E4E70Af364A11350407Be3bBD81",
"to": "0x4e59b44847b379578588920cA78FbF26c0B4956C",
"data": "0x0000…"
},
…
],
"router": {
"bytecode": "0x34…"
}
}The plan lists exactly the facets the source marks. Send each facet's data to its to, in order, then deploy the router's bytecode as you would any creation code, with its deploy arguments appended. Skip a facet whose address already has code, because another contract already deployed it: CREATE2 can't deploy to it twice, and it is already the right code. The router's creation code checks that every facet has code, and reverts if one is missing. deploy can call its facets' functions across as the router's code can.
An upgradeable router is listed with its own address, to, and data, so it can be deployed through the factory too, at an address known in advance.
A contract that creates a contract with facets, with defer create, lists that contract's facets in its plan as well. They must exist before it creates anything.
lithe compile directory writes the router's artifact to out/<file>/<Contract>.json as usual. Its ABI is the whole contract's, because that is what callers use, and it gains a facets field listing each facet's module, address, and the selectors the router forwards to it. Each facet gets one artifact, out/facets/<Module>.json, shared by every contract in the directory that uses it, or out/facets/<Module>-<address prefix>.json for each when contracts build the module into different facets, which removes a <Module>.json an earlier build left. A facet's artifact lists its module's externals; the entrypoints for calls across are in its bytecode, but in no ABI.
Upgrades
An upgradeable contract's router is the implementation behind its ERC-1967 proxy. Its upgrade guard is the only way to change code, and an upgrade is still one upgradeToAndCall. Facets never answer the upgrade entrypoints: proxiableUUID, upgradeToAndCall, and every migration run in the router. Facets are deployed first, and deploying them changes nothing until the proxy points at the new router.
Marking a module delegated, or unmarking it, is an upgrade like any other: the new router runs the module from its facet, or in its own code, behind the same proxy, at the same address, with the same storage.
Every upgrade of a contract with facets deploys a new router, because the router has its facets' addresses built in. A facet whose module didn't change keeps its address and is reused. lithe upgrade-check shows which:
facets
router changed
ERC20 unchanged 0x13F9822a430D5E4E70Af364A11350407Be3bBD81
ERC20Permit new 0x88a0…
compatibleFor a contract with facets, lithe upgrade prints the deployment plan with the proxy's upgradeToAndCall added, and --impl defaults to the new router's CREATE2 address.
Cost
A call to an external that runs in a facet costs one extra delegatecall, measured at about 2,700 gas: the facet's address is cold, and the calldata and return data are copied once more. A call whose code reads immutables costs a little more, for copying them, and behind a proxy the router reads each word of them from storage. A guarded external also decodes its arguments in the router, for the guard, before the facet decodes them again.
A hook a facet calls back costs a delegatecall into the contract's own code, whose address is warm, about 100 gas, plus encoding its arguments and decoding its result, and about 200 gas more for the flag of a stateful hook, and about 20 to look for calls it queued, or 100 for a hook with a result. A hook the contract skips costs a bit test, made before the call binds its arguments when they are plain values. Every call to a facet whose modules have hooks carries one more word of calldata, and a facet whose code reaches a stateful hook checks for queued calls as its body ends, since another contract's hook may defer. With the standard library's ERC721 delegated and both transfer hooks skipped, transferFrom costs about 190 gas more than the same facet without hooks.
A frame that queues calls for its owner costs about 3,500 to 4,500 gas more than one that doesn't, measured with one deferred call and its continuation, on top of what the calls cost inline: saving its memory to transient storage and reading it back, about 300 gas for each word that isn't zero and 30 for each that is, and the delegatecall that drains it. A transition whose frames nest pays for each frame that queues.
A call across costs a staticcall or delegatecall: about 2,600 gas the first time a transaction reaches the facet, for the cold account, and 100 after that. It also pays for encoding its arguments, the facet decoding them and encoding its result, and decoding that. A result that is a large value costs more again, since the caller decodes and copies it into its own memory. The function's own work costs what it would inline. A function called across in a loop pays on every run, so a module the contract calls from a hot loop may be cheaper unmarked.
Each facet carries its own dispatcher and a copy of the modules it requires, so a single contract with facets deploys more bytes in total than its build without them. Contracts that share facets deploy them once.
Flags
| Flag | Default | Meaning |
|---|---|---|
--size-limit <bytes> | 24576 | the most runtime code the router or any facet should have. Creation code may have twice this, as EIP-3860 allows. Code over it still builds, with a warning |
--create2-deployer <address> | 0x4e59…956C | the CREATE2 factory facets are deployed through, for chains where it lives elsewhere |
compile, facets, footprint, abi-tree, upgrade, and upgrade-check take them.
lithe stubs file.lithe Module prints the use Module exposing block that declares every external a module has, to copy into a contract and trim.

