Storage
Lithe has three kinds of state, and each has a different lifetime:
| Kind | Declared as | Accessed as | Lasts |
|---|---|---|---|
| Locals | const / var | name | the current call |
| Transient storage | transient storage [name] is record { ... } | transient.x / name.x | the current transaction |
| Storage | storage [name at "id"] is record { ... } | storage.x / name.x | forever |
| Immutables | immutable [name at "id"] is record { ... } | immutable.x / name.x | forever, set only while the contract is set up |
State is always accessed through a qualified path. A reviewer can always tell a storage read from a local.
Default storage
A contract keeps its state in one default storage record. A contract with no state leaves it out.
import "std/evm";
storage is record {
owner : address;
paused : bool;
total : uint;
balances : map(address, uint);
}
external function claim() is {
storage.owner := Evm.sender();
}The default record starts at slot 0 and is laid out exactly as Solidity lays out the equivalent state variables. Values smaller than 32 bytes pack into shared slots in declaration order. Maps, dynamic lists, string, and bytes use Solidity's hashing and short-string encoding, and records and tuples pack like structs. A Lithe contract and the equivalent Solidity contract produce identical storage, slot for slot.
Print the layout with lithe storage:
lithe storage vault.lithestorage
root: 0x0000000000000000000000000000000000000000000000000000000000000000
owner: address slot 0, offset 0, 20 bytes
paused: bool slot 0, offset 20, 1 byte
total: uint256 slot 1
balances: map(address, uint256) slot 2Named storage
A named domain has its own ERC-7201 root, derived from its id string:
storage settings at "vault.settings" is record {
feeBps : uint;
treasury : address;
}
storage stats at "vault.stats" is record {
volume : uint;
}
storage is record {
owner : address;
}
external function configure(const feeBps : uint; const treasury : address) is {
settings.feeBps := feeBps;
settings.treasury := treasury;
}
external function trade(const amount : uint) is {
stats.volume := stats.volume + amount;
}A named domain is accessed by its name: settings.feeBps. Its root is keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~0xff, the ERC-7201 formula, so it matches Solidity's @custom:storage-location erc7201:vault.settings. Within a domain, fields are laid out like the default record, starting at the root.
Named domains are how modules own state, and how storage stays stable across upgrades. Adding a domain or reordering domains never moves anything else. Two domains with the same id in one contract are a compile error.
Transient storage
Transient storage (EIP-1153) is shared by every call in a transaction and cleared when the transaction ends. It is read with TLOAD and written with TSTORE, which is much cheaper than storage.
import "std/evm";
error Busy;
transient storage is record {
entered : bool;
}
transient storage scratch is record {
touched : map(address, uint);
}
storage is record {
total : uint;
}
external function run(const amount : uint)
raises Busy
is {
assert(not transient.entered, Busy);
transient.entered := true;
scratch.touched[Evm.sender()] := scratch.touched[Evm.sender()] + amount;
storage.total := storage.total + amount;
}The default transient record is accessed as transient.x, and a named one as name.x. A transient field holds a fixed-size value (a word, or a record, tuple, fixed list, option, or variant of fixed-size values) or a map of them. Transient storage is never part of the persistent layout or the upgrade layout, and it has no ERC-7201 id. Use it for flags and bookkeeping that only need to live for one transaction. For reentrancy locks, use nonreentrant, which is built on transient storage already.
Transient storage doesn't pack. Every word of a transient value takes a slot of its own: a bool or a uint8 field, each element of a fixed list, and each word of a map's value. TLOAD and TSTORE cost 100 gas whatever the slot holds, so a shared slot saves nothing by itself. Writing one field of a packed slot takes a TLOAD, a mask, and a TSTORE instead of one TSTORE, and reading one takes a shift and a mask. A slot per word gives the smallest code for every access and the cheapest reads and writes of single fields. Packing would only save gas when a whole record of narrow fields is read or written at once.
Transient slots 0 and 1 hold the reentrancy lock and the upgrade gate. The contract's own transient records follow from slot 2, in order. A module's record starts at a slot derived from its name, so it is the same in every contract that uses the module. lithe storage prints each field's transient slots.
Immutables
An immutable is a value fixed while the contract is set up, such as a factory address, a fee, or a hash, and never written again. Reading one costs about 3 gas, where a first storage read costs 2,100:
import "std/evm";
error FeeTooHigh of uint;
immutable is record {
factory : address;
feeBps : uint16;
}
storage is record {
total : uint;
}
deploy(const feeBps : uint16)
raises FeeTooHigh
is {
assert(feeBps <= 1000, FeeTooHigh(uint(feeBps)));
immutable.factory := Evm.sender();
immutable.feeBps := feeBps;
}
external function trade(const amount : uint) is {
storage.total := storage.total + amount * uint(immutable.feeBps) / 10_000;
}
external view function factory() : address is
immutable.factoryThe contract's own block is read as immutable.x. A named block has an id, as named storage does, and a module declares its immutables that way:
module Fees is {
immutable fees at "vault.fees" is record {
bps : uint16;
treasury : address;
}
function init(const bps : uint16; const treasury : address) is {
fees.bps := bps;
fees.treasury := treasury;
}
view function cut(const amount : uint) : uint is
amount * uint(fees.bps) / 10_000
}
use Fees;
storage is record {
total : uint;
}
deploy(const treasury : address) is {
Fees.init(25, treasury);
}
external function trade(const amount : uint) is {
storage.total := storage.total + Fees.cut(amount);
}Names and ids follow named storage's rules. An immutable block and a storage domain share one set of ids, so two blocks with the same id in one contract are a compile error, whether each is storage or immutable.
Writing immutables
Immutables are written only while the contract is set up: by deploy, by a migration in an upgradeable contract, and by the functions they call. A function that writes an immutable, itself or through a function it calls, may be called only from those, or from other functions that keep the same rule. The compiler works this out from the calls, and rejects any other path to a write where it starts:
import "std/evm";
immutable is record {
owner : address;
}
storage is record {
total : uint;
}
function claim() is {
immutable.owner := Evm.sender();
}
deploy() is {
claim();
}
external function reclaim() is {
claim();
}external function `reclaim` calls `claim`, which writes immutable `immutable.owner`; call it only from `deploy` or the functions it callsWithin deploy or a migration, an immutable can be written more than once, and a read sees the value written last. A field never written reads as zero.
What immutables can hold
A field holds a word: an integer of any width, bool, address, an interface, bytesN, wei, timestamp, or an enum. It can also hold a record or tuple of these. Maps and lists are collections, and belong in storage. string and bytes values can't be immutables yet.
Where immutables live
- A plain contract builds them into its code, as Solidity does. Each read is a push of a constant that the creation code writes in after
deployreturns, only as wide as its type: 20 bytes for an address, 2 for auint16. Nothing is stored. - An upgradeable contract can't: its code is the implementation's, shared by every proxy. Its immutables are kept in the proxy's storage instead, laid out as named storage at the ERC-7201 root of their id. The contract's own block has the id
lithe.immutable.deploywrites them there as it runs in the proxy, and a migration can set a field that a later version adds. They show inlithe storageandlithe upgrade-checkwith the rest of the layout. - A facet reads its module's immutables from its router, after the calldata.
What storage can hold
| Type | In storage |
|---|---|
integers, bool, address, bytesN, wei, timestamp, interfaces | yes |
string, bytes | yes |
| records, tuples | yes, packed |
list(T), list(T, n) | yes, for any value T: words, strings, bytes, records, tuples, options, and other lists, laid out as a Solidity array. Elements narrower than 32 bytes share slots, low bytes first, as many as fit: 32 uint8s or bools, 16 uint16s, one address. A record element starts a slot and packs like a struct in a Solidity array |
map(K, V) | yes. K is a word, a string, or bytes. V is a word, a map, a list, an option, a tuple, or a record, which may hold maps, lists, and strings itself |
option(T) | yes. One slot when T is narrower than 32 bytes. Otherwise a tag slot, then T's slots |
| variants | yes. One byte when no case has a payload, packed. Otherwise laid out as a record: the case's index packed with the narrow payload words, then the rest. See Variants |
Solidity has no options, so their layout is Lithe's own. See Options.
Reading and writing
A missing map key, an unwritten field, and an unwritten list slot all read as zero. delete place; writes that zero value back, for a value of any type: a number becomes 0, a string or list empty, an option None, a record each of its fields' zeros. It clears every slot the old value held, a long string's data and a list's elements included, as Solidity's delete does. See delete.
Zeroing a slot refunds gas, up to a fifth of the transaction's, so deleting pays when the slot is already warm: read in the same transaction. Deleting a slot nothing else touches costs its cold access, more than the refund.
You can write one field of a stored record, or a whole record into a field or a map entry. A record that holds strings, bytes, or lists writes each of them as its own type does:
type Order is record {
status : uint8;
amount : uint;
}
storage is record {
orders : map(uint, Order);
}
external function place(const id : uint; const amount : uint) is {
storage.orders[id] := { status = uint8(1); amount = amount; };
}
external function fill(const id : uint) is {
storage.orders[id].status := uint8(2);
}When writes land
Within a transition, reads see earlier writes, as you would expect. Writes are committed before events are logged and before any deferred call runs. A deferred callee, or anything it calls, always sees your transition's final state. See The transition model.

