State roots: independent block verification
The POA producer computes a state root after executing each block and commits it in the block hash. A verifier re-executes the transactions against its own previous state and compares the result.
This detects discrepancies between execution and committed state. By itself it does not guarantee availability, prevent producer equivocation, or provide independent consensus on which branch to follow.
What goes into the state root
Section titled “What goes into the state root”The current Chain.StateRoot implementation uses composition version 9. Eighteen 32-byte components enter the hash in this order:
state_root = keccak256( accounts_root || htlc_root || precompiles_root || bridge_mints_root || bridge_halt_consensus_root || bridge_governors_state_root || bridge_unhalt_pending_root || authorization_current_producer_root || authorization_forks_root || authorization_accepted_root || chain_params_root || x402_authorization_nonces_root || confidential_accounts_root || confidential_lane_total_root || confidential_nonces_root || confidential_inbound_notes_root || confidential_frozen_recipients_root || confidential_note_cap_exempt_root)Slots 1–12 and slot 14 are the version-8 bytes of those components. Slot 13 is the confidential-accounts tree. Slot 15 is the confidential-nonces tree. Slots 16–18 are the inbound-note list, the frozen-recipient list, and the note-cap exemption list.
Confidential accounts and nonces are fixed-depth sparse Merkle trees of depth 256. Bit index 0 is the high bit of the first key byte. A 0 bit selects the left child and a 1 bit selects the right child. The account key is keccak256 of the raw 20-byte address. The nonce key is keccak256 of the raw 20-byte from concatenated with the 32-byte nonce. empty_node(256) is 32 zero bytes. For d from 255 down to 0, empty_node(d) is keccak256 of empty_node(d+1) concatenated with itself. empty_node(255) is ad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5. The empty tree root is empty_node(0), which is not the 32-byte zero word. A leaf value is the version-8 row hash and is not hashed again. An account leaf is keccak256 of the address concatenated with the 32-byte commitment. A nonce leaf is keccak256 of from, the nonce, and used_at_block as a signed 64-bit integer. An internal node is keccak256 of the left child concatenated with the right child. A missing child is empty_node(d+1).
An empty hash_list is 32 zero bytes. A nonempty hash_list is keccak256 of the concatenated 32-byte row hashes. Frozen recipients and note-cap exemptions are each a hash_list of keccak256 of the raw 20-byte address, sorted ascending. Inbound notes are in neither tree. Their list is a hash_list of keccak256 of from, nonce, to, c_v, and created_block as a signed 64-bit integer, ordered by (from, nonce). inserted_at is not hashed. The lane total is keccak256 of the decimal encoding of the amount, or of zero when the row is absent.
At the version-9 rotation, precompile 0007 stays disabled, so the inbound-note list, state.confidential_note_cap_exempt, and state.confidential_frozen_recipients are empty. Historical replay across this rotation is unsupported. A node syncs from a backup taken after the rotation. That backup includes created_block, the incremental account and nonce roots, the empty exemption list, and the empty frozen-recipient list.
The state groups are:
accounts: addresses, balances, and nonces.htlc_swaps: locks, receivers, deadlines, and statuses.precompiles: handler registry and activation settings.bridge_mints: source Ethereum events, HTLC bindings, and observation block.- Bridge halt, governor, and pending-unhalt state.
- Current producer, fork authorizations, and accepted tickets.
chain_params: chain identity parameters.x402_authorization_nonces: consumed payment authorizations, preventing replay.- Confidential account commitments and consumed confidential nonces, as the trees above.
- The confidential lane total, inbound notes, frozen recipients, and note-cap exemptions.
Fields and row ordering are defined in lib/chain/state_root.ex. Table roots use canonical row hashes; numerically equal Decimals encode identically. This composition overview does not replace the byte-level encoding specification.
The blocks_tip cache is excluded because the root is computed before updating the head pointer. The confidential root cache and its checkpoints are not preimage components. Local metrics and operational records are not automatically consensus state.
Composition, encoding, or ordering changes require a coordinated producer/verifier upgrade. The configured state_root_version must match the code on every node; a mismatch stops processing with :state_root_version_mismatch.
How the block hash commits to it
Section titled “How the block hash commits to it”Every block’s hash is now:
block_hash = keccak256( block_number (8 bytes) || parent_hash (32 bytes) || timestamp (8 bytes) || tx_root (32 bytes, hash of all tx hashes in execution order) || state_root (32 bytes, the sorted-hash root above))This means changing any account balance, any HTLC status, or any precompile registration without a corresponding valid transaction changes the state root, which changes the block hash, which breaks the parent-hash chain for every subsequent block. A single tampered row cascades into a detectable chain break.
What a verifier does with this
Section titled “What a verifier does with this”The chain ships an independent verifier client. For every block, it:
- Receives the block (header + raw signed transactions) from the upstream node.
- Replays the transactions against its own copy of the previous state.
- Computes its own state root.
- Compares with the producer’s claimed root.
- If they match: commits the block and serves the verified state to wallets and RPC clients.
- If they don’t: rolls back, logs a critical warning, refuses to serve.
The verifier runs as a separate BEAM node with its own database. It has no direct access to the producer’s storage. Clients can read verified state through verifier RPC; transaction submission uses a separate public RPC. Multiple verifiers can run independently. Anyone can run one.
A verifier that commits a block re-broadcasts it on its own block feed. Other verifiers can subscribe to it instead of the producer directly. This means verifiers can form a chain or a tree:
Producer ──▶ Verifier A ──▶ Verifier C ▶ Verifier B ──▶ Verifier DEach verifier independently verifies every block regardless of where it received it from. The trust model is the same whether the upstream is the producer or another verifier: verify, then serve.
How blocks and transactions travel between nodes
Section titled “How blocks and transactions travel between nodes”Producer and verifier run as separate BEAM nodes and exchange blocks through Erlang distribution. Operators must configure TLS and private network access for this channel; Erlang distribution alone does not enable encryption.
Blocks (producer to verifiers): after each block’s database transaction commits, the producer emits a block event through a GenStage pipeline. GenStage is an Elixir library for backpressure-aware producer-consumer flows. Every subscribed verifier receives every block (broadcast fan-out, not round-robin). If a verifier falls behind or disconnects, the producer buffers up to 1000 blocks; older ones are dropped.
When a verifier starts (or reconnects after a gap), it catches up before subscribing to the live feed. It asks the upstream node for its current tip, then fetches blocks in batches from its own last known block, verifying each one. Once caught up, it switches to the live GenStage subscription. The same mechanism works whether the upstream is the producer or another verifier.
The block event carries the header and raw signed transactions in execution order. Verifiers recover Ethereum senders from signed RLP and re-verify Tron signatures against the raw protobuf and claimed sender.
Transaction submission: the public wallet RPC accepts signed transactions on the producer write path. A standalone verifier is read-only: eth_sendRawTransaction and Tron broadcasts are rejected in verifier mode. Point wallets that need to send at the public POA RPC.
The public edge publishes only the allowed RPC and API routes. Private node and administration listeners remain behind their network boundary. Read-only verifier mode and public write routing are distinct deployment roles.
Additional verifiers can chain off the first one instead of connecting to the producer directly. Each verifier re-broadcasts verified blocks, so the topology can be a star, a chain, or a tree depending on the deployment.
Current approach and future scaling
Section titled “Current approach and future scaling”The current implementation is a sorted-hash: it queries every row from each state table, sorts by primary key, hashes, and concatenates. The full-state scan is O(n) per block. Performance depends on state size and hardware and must be measured for the deployment.
When the state grows, the plan is to migrate to an incremental Merkle tree that updates only the rows changed in the current block. That brings the per-block cost down to O(k log n), where k is the number of changed rows. Such a migration is a consensus change: it needs a versioned rollout so every verifier computes the same root for the same block.
Why this matters for cross-chain bridges
Section titled “Why this matters for cross-chain bridges”The bridge uses the state root to commit bridge_mints alongside other state. The verifier separately checks Ethereum events through its configured RPC. A state root binds the result to the block; it does not verify Ethereum by itself. The cross-chain data source remains a separate trust boundary. See Bridge.