State trie¶
Authenticated state/storage traversal and post-state update assembly over the shared MPT and state-leaf codec.
function stateless_account_by_key¶
The witnessed account at address a under state root root,
reading the secure trie at keccak256(a); a walk that proves absence
yields [EMPTY_ACCOUNT], whose present field is the absence witness.
function stateless_account_by_key(root : hash, address_hash : hash) -> Account = {
let path = path_new(address_hash, 64);
let value = trie_lookup(root, path);
if value.len == 0 then {
EMPTY_ACCOUNT
} else {
let account_info = decode_state_account(value);
account_from_info(account_info)
}
}Wraps a witnessed account tuple as an existing, unmodified account.
function account_from_info(info : AccountInfo) -> Account =
struct { info = info, present = true, storage_cleared = false, created = false, selfdestructed = false }Decodes an account trie leaf — rlp([nonce, balance, storage_root,
code_hash]) — into an AccountInfo; empty
root/hash fields decode to their empty-sentinel digests.
function decode_state_account(value : StatelessInputSlice) -> AccountInfo = {
let fields = rlp_node_cursor(value);
let nonce = rlp_decode_item(fields);
let fields = rlp_cursor_advance(fields, nonce.source.len);
let balance = rlp_decode_item(fields);
let fields = rlp_cursor_advance(fields, balance.source.len);
let storage = rlp_decode_item(fields);
let fields = rlp_cursor_advance(fields, storage.source.len);
let code = rlp_decode_item(fields);
let fields = rlp_cursor_advance(fields, code.source.len);
rlp_cursor_expect_end(fields);
let storage_root =
if storage.content_len == 0 then {
EMPTY_TRIE_ROOT
} else {
let storage_word = rlp_decode_word(storage);
word_to_hash(storage_word)
};
let code_hash =
if code.content_len == 0 then {
KECCAK_EMPTY
} else {
let code_word = rlp_decode_word(code);
word_to_hash(code_word)
};
struct {
nonce = rlp_decode_uint64(nonce),
balance = rlp_decode_u256(balance),
storage_root = storage_root,
code_hash = code_hash,
}
}Constructs a path from high-aligned data and a nibble length.
function path_new(data : b256, len : trie_path_len) -> TriePath =
struct { data = data, len = len }The witnessed account at address a under state root root,
reading the secure trie at keccak256(a); a walk that proves absence
yields [EMPTY_ACCOUNT], whose present field is the absence witness.
function stateless_account_by_key(root : hash, address_hash : hash) -> Account = {
let path = path_new(address_hash, 64);
let value = trie_lookup(root, path);
if value.len == 0 then {
EMPTY_ACCOUNT
} else {
let account_info = decode_state_account(value);
account_from_info(account_info)
}
}Looks up key from a root hash; the root node itself must be
witnessed.
function trie_lookup(root : hash, key : TriePath) -> StatelessInputSlice = {
if root == EMPTY_TRIE_ROOT then {
EMPTY_STATELESS_INPUT_SLICE
} else {
let node = node_db_lookup(root);
if node.len == 0 then {
fatal_error(WitnessDeficient)
} else {
trie_walk(node, key, 0)
}
}
}The non-existent account sentinel (EIP-161 "empty").
let EMPTY_ACCOUNT : Account = struct {
info = EMPTY_ACCOUNT_INFO,
present = false,
storage_cleared = true,
created = false,
selfdestructed = false,
}An account plus its lifecycle flags: existence, EIP-161 storage clearing, same-transaction creation (EIP-6780), and selfdestruction.
struct Account = {
info : AccountInfo,
present : bool,
storage_cleared: bool,
created : bool,
selfdestructed : bool,
}The common digest type used by trie, code, and block hashes.
type hash = b256function stateless_storage_by_key¶
The witnessed storage value of slot under a storage root, reading
the secure trie at keccak256(slot); absent slots are zero.
function stateless_storage_by_key(root : hash, slot_hash : hash) -> word = {
let path = path_new(slot_hash, 64);
let value = trie_lookup(root, path);
if value.len == 0 then {
ZERO_WORD
} else {
let encoded_value = rlp_single_ref(value);
rlp_decode_u256(encoded_value)
}
}Constructs a path from high-aligned data and a nibble length.
function path_new(data : b256, len : trie_path_len) -> TriePath =
struct { data = data, len = len }Decodes a canonical unsigned integer field into a word; throws otherwise.
function rlp_decode_u256 forall 'source_off 'source_len 'content_len,
rlp_field_ref_valid('source_off, 'source_len, 'content_len). (f :
RlpFieldRef('source_off, 'source_len, 'content_len)) -> (
word
) =
let canonical = rlp_item_uint_canonical(f) in
if canonical then {
rlp_decode_word(f)
} else {
fatal_error(RlpDecode)
}function rlp_single_ref(item) = {
let item_length = item.len;
if item_length == 0 then {
fatal_error(RlpDecode)
} else {
let (is_list, content_off, content_len_value) = rlp_ref_hdr(item);
let (content_len as 'content_len) = content_len_value;
if (content_off <= item_length) & (content_len == item_length - content_off) then {
let field : RlpFieldRef('source_off, 'source_len, 'content_len) = struct {
source = item,
is_list = is_list,
content_len = content_len,
};
field
} else {
fatal_error(RlpDecode)
}
}
}The witnessed storage value of slot under a storage root, reading
the secure trie at keccak256(slot); absent slots are zero.
function stateless_storage_by_key(root : hash, slot_hash : hash) -> word = {
let path = path_new(slot_hash, 64);
let value = trie_lookup(root, path);
if value.len == 0 then {
ZERO_WORD
} else {
let encoded_value = rlp_single_ref(value);
rlp_decode_u256(encoded_value)
}
}Looks up key from a root hash; the root node itself must be
witnessed.
function trie_lookup(root : hash, key : TriePath) -> StatelessInputSlice = {
if root == EMPTY_TRIE_ROOT then {
EMPTY_STATELESS_INPUT_SLICE
} else {
let node = node_db_lookup(root);
if node.len == 0 then {
fatal_error(WitnessDeficient)
} else {
trie_walk(node, key, 0)
}
}
}let ZERO_WORD : word = word_from_bits(0x0000000000000000000000000000000000000000000000000000000000000000)The common digest type used by trie, code, and block hashes.
type hash = b256The EVM 256-bit machine word (YP §9.1). A transparent range keeps the mathematical subtype relation visible: narrower non-negative ranges can be passed as words without a model-level conversion.
type word = range(0, 2 ^ 256 - 1)function storage_value_changed¶
function storage_value_changed(value : StorageValue) -> bool =
not_bool(value.curr == value.orig)val not_bool = pure {coq: "negb", lean: "_lean_not", _: "not"}: forall ('p : Bool). bool('p) -> bool(not('p))A storage slot's current and original (transaction-start) values — the pair EIP-2200/EIP-3529 gas and refund rules compare.
struct StorageValue = { curr : word, orig : word }function account_value_changed¶
Whether any persisted account field changed across the block.
function account_value_changed(value : AcctValue) -> bool =
not_bool(value.curr.info.nonce == value.orig.info.nonce)
| not_bool(value.curr.info.balance == value.orig.info.balance)
| not_bool(value.curr.info.storage_root == value.orig.info.storage_root)
| not_bool(value.curr.info.code_hash == value.orig.info.code_hash)
| not_bool(value.curr.present == value.orig.present)
| not_bool(value.curr.storage_cleared == value.orig.storage_cleared)val not_bool = pure {coq: "negb", lean: "_lean_not", _: "not"}: forall ('p : Bool). bool('p) -> bool(not('p))An account's current and original (transaction-start) states.
struct AcctValue = { curr : Account, orig : Account }function storage_update¶
Converts one changed storage row into a secure-trie update.
function storage_update(trie_entry : StorageTrieEntry) -> TrieUpdate = {
let entry = trie_entry.entry;
let key = path_new(trie_entry.slot_hash, 64);
let value_is_zero = word_is_zero(entry.value.curr);
let change =
if value_is_zero then {
TrieDelete()
} else {
let encoded_value = encode_storage_value(entry.value.curr);
TriePut(encoded_value)
};
struct { key = key, change = change }
}Encodes a nonzero storage value as its minimal RLP integer leaf payload.
function encode_storage_value(value : word) -> ScratchSlice = {
let encoded_len = rlp_uint_word_size(value);
let encoder = rlp_encoder_begin(encoded_len);
rlp_write_uint_word(value);
rlp_encoder_finish(encoder)
}Constructs a path from high-aligned data and a nibble length.
function path_new(data : b256, len : trie_path_len) -> TriePath =
struct { data = data, len = len }function word_is_zero(w) = w == WORD_ZEROA host storage row prepared for secure-trie traversal. The semantic row remains StorageEntry; these cached digests are derived traversal metadata computed when the witness value is first materialized.
struct StorageTrieEntry = {
entry : StorageEntry,
address_hash : hash,
slot_hash : hash,
}An update: a full-path key and its change. Sources yield updates in ascending key order.
struct TrieUpdate = { key : TriePath, change : TrieChange }function account_update¶
Converts one account row and its recomputed storage root into a state-trie insertion or deletion.
function account_update(trie_entry : AcctTrieEntry, storage_root : hash) -> TrieUpdate = {
let entry = trie_entry.entry;
let current = entry.value.curr;
let key = path_new(trie_entry.address_hash, 64);
let account_absent = not_bool(current.present);
let account_empty = account_info_empty(current.info);
if account_absent | account_empty then {
struct { key = key, change = TrieDelete() }
} else {
let encoded_account = encode_state_account(current.info, storage_root);
struct { key = key, change = TriePut(encoded_account) }
}
}The EIP-161 emptiness test: no code, zero nonce, zero balance.
function account_info_empty(info : AccountInfo) -> bool =
(info.code_hash == KECCAK_EMPTY) & info.nonce == 0 & word_is_zero(info.balance)Encodes an account trie leaf with its recomputed storage root.
function encode_state_account(info : AccountInfo, storage_root : hash) -> ScratchSlice = {
let nonce_length = rlp_uint_size(info.nonce);
let balance_length = rlp_uint_word_size(info.balance);
let storage_root_length = rlp_word_size();
let code_hash_length = rlp_word_size();
let content_len = nonce_length + balance_length + storage_root_length + code_hash_length;
let encoded_length = rlp_list_size(content_len);
let encoder = rlp_encoder_begin(encoded_length);
rlp_write_list_prefix(content_len);
rlp_write_uint(info.nonce);
rlp_write_uint_word(info.balance);
let storage_root_word = hash_to_word(storage_root);
rlp_write_word(storage_root_word);
let code_hash_word = hash_to_word(info.code_hash);
rlp_write_word(code_hash_word);
rlp_encoder_finish(encoder)
}val not_bool = pure {coq: "negb", lean: "_lean_not", _: "not"}: forall ('p : Bool). bool('p) -> bool(not('p))Constructs a path from high-aligned data and a nibble length.
function path_new(data : b256, len : trie_path_len) -> TriePath =
struct { data = data, len = len }A host account row prepared for secure-trie traversal.
struct AcctTrieEntry = {
entry : AcctEntry,
address_hash : hash,
}An update: a full-path key and its change. Sources yield updates in ascending key order.
struct TrieUpdate = { key : TriePath, change : TrieChange }The common digest type used by trie, code, and block hashes.
type hash = b256function next_storage_trie_update¶
Pulls the next changed storage row and converts it to a trie update, skipping materialized read-only rows.
function next_storage_trie_update(addr : address) -> TrieUpdateFetch = {
var searching : bool = true;
var result : TrieUpdateFetch = struct { available = false, update = EMPTY_TRIE_UPDATE };
while searching termination_measure(2 ^ 64) do {
let iterator_item = storage_block_iter_next(addr);
match iterator_item {
StorageBlockIterRow(trie_entry) => {
let changed = storage_value_changed(trie_entry.entry.value);
if changed then {
result = struct { available = true, update = storage_update(trie_entry) };
searching = false
}
},
StorageBlockIterExhausted(_) => searching = false,
}
};
result
}We have special support for raising values to the power of two. Any Sail expression 2 ^ x will be compiled to this builtin.
val pow2 = pure {lean: "_lean_pow2i", _: "pow2"}: forall ('n : Int). int('n) -> int(2 ^ 'n)Returns the next materialized storage row and its cached secure keys in ascending slot-key order, or reports traversal exhaustion.
val storage_block_iter_next = impure { c: "storage_block_iter_next" } : address -> StorageBlockIterResultConverts one changed storage row into a secure-trie update.
function storage_update(trie_entry : StorageTrieEntry) -> TrieUpdate = {
let entry = trie_entry.entry;
let key = path_new(trie_entry.slot_hash, 64);
let value_is_zero = word_is_zero(entry.value.curr);
let change =
if value_is_zero then {
TrieDelete()
} else {
let encoded_value = encode_storage_value(entry.value.curr);
TriePut(encoded_value)
};
struct { key = key, change = change }
}function storage_value_changed(value : StorageValue) -> bool =
not_bool(value.curr == value.orig)The payload sentinel used by exhausted source pulls.
let EMPTY_TRIE_UPDATE : TrieUpdate = struct { key = path_empty(), change = TrieDelete() }One block-layer storage iterator row, or iterator exhaustion.
union StorageBlockIterResult = {
/* the next block-layer storage row, with its cached traversal digests */
StorageBlockIterRow : StorageTrieEntry,
/* the iterator has yielded every row */
StorageBlockIterExhausted : unit,
}One pull from an ordered update source. update is meaningful exactly
when available is true.
struct TrieUpdateFetch = { available : bool, update : TrieUpdate }A 20-byte account address (YP §4.1), in canonical protocol byte order.
type address = vector(20, inc, byte)function account_trie_update¶
Computes one account update directly from its ordered storage-update source. Opening the cursor loads at most one changed storage row; that loaded item both proves whether storage changed and remains owned by the cursor when the generic trie reducer consumes it. Consequently post-storage roots need no auxiliary host cache or second account traversal.
function account_trie_update(trie_entry : AcctTrieEntry) -> (TrieUpdate, bool) = {
let entry = trie_entry.entry;
let current = entry.value.curr;
storage_block_iter_begin(entry.addr);
let storage_source = StorageTrieUpdates(entry.addr);
let storage_updates = trie_updates_begin(storage_source);
let no_storage_changes = updates_empty(storage_updates);
let storage_changed = not_bool(no_storage_changes);
let base_storage_root =
if current.storage_cleared then EMPTY_TRIE_ROOT else current.info.storage_root;
let account_empty = account_info_empty(current.info);
let account_nonempty = not_bool(account_empty);
let storage_root =
if current.present & account_nonempty & storage_changed
then trie_root_cursor(base_storage_root, storage_updates).root
else base_storage_root;
let update = account_update(trie_entry, storage_root);
let persisted_account_changed = account_value_changed(entry.value);
(update, persisted_account_changed | storage_changed)
}The EIP-161 emptiness test: no code, zero nonce, zero balance.
function account_info_empty(info : AccountInfo) -> bool =
(info.code_hash == KECCAK_EMPTY) & info.nonce == 0 & word_is_zero(info.balance)Converts one account row and its recomputed storage root into a state-trie insertion or deletion.
function account_update(trie_entry : AcctTrieEntry, storage_root : hash) -> TrieUpdate = {
let entry = trie_entry.entry;
let current = entry.value.curr;
let key = path_new(trie_entry.address_hash, 64);
let account_absent = not_bool(current.present);
let account_empty = account_info_empty(current.info);
if account_absent | account_empty then {
struct { key = key, change = TrieDelete() }
} else {
let encoded_account = encode_state_account(current.info, storage_root);
struct { key = key, change = TriePut(encoded_account) }
}
}Whether any persisted account field changed across the block.
function account_value_changed(value : AcctValue) -> bool =
not_bool(value.curr.info.nonce == value.orig.info.nonce)
| not_bool(value.curr.info.balance == value.orig.info.balance)
| not_bool(value.curr.info.storage_root == value.orig.info.storage_root)
| not_bool(value.curr.info.code_hash == value.orig.info.code_hash)
| not_bool(value.curr.present == value.orig.present)
| not_bool(value.curr.storage_cleared == value.orig.storage_cleared)val not_bool = pure {coq: "negb", lean: "_lean_not", _: "not"}: forall ('p : Bool). bool('p) -> bool(not('p))Starts a non-destructive ascending traversal of the block-layer storage rows for an address. The iterator is valid until the block layer is mutated.
val storage_block_iter_begin = impure { c: "storage_block_iter_begin" } : address -> unitApplies an already-open update cursor. The changed result records
whether the source contained at least one update.
function trie_root_cursor(base_root : hash, updates : TrieUpdateCursor) -> TrieRootResult = {
let no_updates = updates_empty(updates);
if no_updates then {
struct { root = base_root, changed = false }
} else {
let empty_prefix = path_empty();
let (subtree, remaining) =
if base_root == EMPTY_TRIE_ROOT then {
updates_subtree(updates, empty_prefix, 0)
} else {
let node = node_db_lookup(base_root);
if node.len == 0 then {
fatal_error(WitnessDeficient)
} else {
witness_subtree(node, empty_prefix, updates, 0)
}
};
let all_updates_consumed = updates_empty(remaining);
if all_updates_consumed then {
struct { root = trie_subtree_root(subtree), changed = true }
} else {
fatal_error(WitnessDeficient)
}
}
}Opens a pull cursor by fetching only its first update.
function trie_updates_begin(source : TrieUpdateSource) -> TrieUpdateCursor = {
let first = trie_update_source_next(source);
let relation : TrieUpdateRelation =
if first.available then UpdateUnderPrefix(first.update.key) else UpdateSourceExhausted();
struct { source = source, current = first.update, relation = relation }
}Whether the pull cursor has reached the end of its source.
function updates_empty(updates : TrieUpdateCursor) -> bool =
match updates.relation {
UpdateSourceExhausted(_) => true,
_ => false,
}keccak256(rlp("")) — the root of an empty Merkle-Patricia trie: the
storage root of every account with no storage (EMPTY_ACCOUNT, freshly
created).
let EMPTY_TRIE_ROOT : hash = hash_from_bits(0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421)A host account row prepared for secure-trie traversal.
struct AcctTrieEntry = {
entry : AcctEntry,
address_hash : hash,
}An update: a full-path key and its change. Sources yield updates in ascending key order.
struct TrieUpdate = { key : TriePath, change : TrieChange }function next_changed_account_trie_update¶
Pulls the next net-changed account update for the protocol state-root path. The host excludes read-only candidates; Sail skips reverted/no-op writes.
function next_changed_account_trie_update() -> TrieUpdateFetch = {
var searching : bool = true;
var result : TrieUpdateFetch = struct { available = false, update = EMPTY_TRIE_UPDATE };
while searching termination_measure(2 ^ 64) do {
let iterator_item = acct_block_iter_next();
match iterator_item {
AcctBlockIterRow(entry) => {
let (update, changed) = account_trie_update(entry);
if changed then {
result = struct { available = true, update = update };
searching = false
}
},
AcctBlockIterExhausted(_) => searching = false,
}
};
result
}Computes one account update directly from its ordered storage-update source. Opening the cursor loads at most one changed storage row; that loaded item both proves whether storage changed and remains owned by the cursor when the generic trie reducer consumes it. Consequently post-storage roots need no auxiliary host cache or second account traversal.
function account_trie_update(trie_entry : AcctTrieEntry) -> (TrieUpdate, bool) = {
let entry = trie_entry.entry;
let current = entry.value.curr;
storage_block_iter_begin(entry.addr);
let storage_source = StorageTrieUpdates(entry.addr);
let storage_updates = trie_updates_begin(storage_source);
let no_storage_changes = updates_empty(storage_updates);
let storage_changed = not_bool(no_storage_changes);
let base_storage_root =
if current.storage_cleared then EMPTY_TRIE_ROOT else current.info.storage_root;
let account_empty = account_info_empty(current.info);
let account_nonempty = not_bool(account_empty);
let storage_root =
if current.present & account_nonempty & storage_changed
then trie_root_cursor(base_storage_root, storage_updates).root
else base_storage_root;
let update = account_update(trie_entry, storage_root);
let persisted_account_changed = account_value_changed(entry.value);
(update, persisted_account_changed | storage_changed)
}Returns the next account update candidate and its cached secure key in ascending secure-key order, or reports traversal exhaustion.
val acct_block_iter_next = impure { c: "acct_block_iter_next" } : unit -> AcctBlockIterResultWe have special support for raising values to the power of two. Any Sail expression 2 ^ x will be compiled to this builtin.
val pow2 = pure {lean: "_lean_pow2i", _: "pow2"}: forall ('n : Int). int('n) -> int(2 ^ 'n)The payload sentinel used by exhausted source pulls.
let EMPTY_TRIE_UPDATE : TrieUpdate = struct { key = path_empty(), change = TrieDelete() }One block-layer account iterator row, or iterator exhaustion.
union AcctBlockIterResult = {
/* the next block-layer account row, with its cached address digest */
AcctBlockIterRow : AcctTrieEntry,
/* the iterator has yielded every row */
AcctBlockIterExhausted : unit,
}One pull from an ordered update source. update is meaningful exactly
when available is true.
struct TrieUpdateFetch = { available : bool, update : TrieUpdate }function trie_update_source_next¶
State-backed implementation of the generic trie's pull-source contract.
function trie_update_source_next(source : TrieUpdateSource) -> TrieUpdateFetch =
match source {
StorageTrieUpdates(addr) => next_storage_trie_update(addr),
ChangedAccountTrieUpdates() => next_changed_account_trie_update(),
}Pulls the next net-changed account update for the protocol state-root path. The host excludes read-only candidates; Sail skips reverted/no-op writes.
function next_changed_account_trie_update() -> TrieUpdateFetch = {
var searching : bool = true;
var result : TrieUpdateFetch = struct { available = false, update = EMPTY_TRIE_UPDATE };
while searching termination_measure(2 ^ 64) do {
let iterator_item = acct_block_iter_next();
match iterator_item {
AcctBlockIterRow(entry) => {
let (update, changed) = account_trie_update(entry);
if changed then {
result = struct { available = true, update = update };
searching = false
}
},
AcctBlockIterExhausted(_) => searching = false,
}
};
result
}Pulls the next changed storage row and converts it to a trie update, skipping materialized read-only rows.
function next_storage_trie_update(addr : address) -> TrieUpdateFetch = {
var searching : bool = true;
var result : TrieUpdateFetch = struct { available = false, update = EMPTY_TRIE_UPDATE };
while searching termination_measure(2 ^ 64) do {
let iterator_item = storage_block_iter_next(addr);
match iterator_item {
StorageBlockIterRow(trie_entry) => {
let changed = storage_value_changed(trie_entry.entry.value);
if changed then {
result = struct { available = true, update = storage_update(trie_entry) };
searching = false
}
},
StorageBlockIterExhausted(_) => searching = false,
}
};
result
}State-backed implementation of the generic trie's pull-source contract.
function trie_update_source_next(source : TrieUpdateSource) -> TrieUpdateFetch =
match source {
StorageTrieUpdates(addr) => next_storage_trie_update(addr),
ChangedAccountTrieUpdates() => next_changed_account_trie_update(),
}One pull from an ordered update source. update is meaningful exactly
when available is true.
struct TrieUpdateFetch = { available : bool, update : TrieUpdate }The closed pull-source algebra for ordered trie updates. Each variant owns an independently opened host iterator and trie_update_source_next is its sole interpreter, allowing one cursor and rebuild algorithm without function-valued callbacks.
union TrieUpdateSource = {
/* changed storage rows for one account */
StorageTrieUpdates : address,
/* block-final account update candidates, net-filtered in Sail */
ChangedAccountTrieUpdates : unit,
}function compute_state_root¶
The post-state root: traverses every changed account in the kernel's block-level overlay, recomputes each touched account's storage root from its changed slots (zero-valued slots delete), re-encodes the account leaf (empty accounts delete, per EIP-161), and streams the ordered updates into the parent state root via trie_root.
function compute_state_root() -> hash = {
acct_block_iter_begin();
let updates = ChangedAccountTrieUpdates();
trie_root(k_parent_state_root, updates).root
}Starts a non-destructive ascending traversal of block-layer account update candidates. Candidates were written directly or have written storage rows; Sail decides whether their final current/original values differ. The iterator is valid until the block layer is mutated.
val acct_block_iter_begin = impure { c: "acct_block_iter_begin" } : unit -> unitThe root of the trie after pulling and applying the source's ordered updates. The source's host iterator must be opened by its owner first.
function trie_root(base_root : hash, source : TrieUpdateSource) -> TrieRootResult =
let updates = trie_updates_begin(source) in
trie_root_cursor(base_root, updates)The authenticated parent state root, the anchor of every stateless read.
register k_parent_state_root : hash = ZERO_HASHThe common digest type used by trie, code, and block hashes.
type hash = b256