Chain
Blocks, transactions and state
Seventeen transaction variants, one executor, and a header that commits to the state a block executes against rather than the state it produces.
What the chain layer is
A UniNet chain is an ordered list of blocks and a sparse Merkle trie of state. A block carries signed transactions; applying it runs those transactions in order against the trie and produces a receipt for each. The trie's root hash is the state root, and it is the value every node compares against every other node.
The problem this layer addresses is convergence. A transaction can arrive at a node over HTTP, out of the mempool, inside a peer's proposal, or during block-sync from a node that was offline. If any of those routes can write state by a path the others do not take, two honest nodes end up with different roots while both believe they are correct. So every route by which a transaction changes state lands in the same function: BlockExecutor::execute_tx_logic. Rules that could have lived in the HTTP handler — supply minting, soul-bound refusal, ownership — are enforced there instead, because the HTTP handler is the one route most transactions never travel. It is not an absolute rule about the node: a few handlers still move balances directly, outside any transaction, and those writes are listed below.
How a transaction reaches state
A transaction is signed with an Ed25519 key and carries the signer's public key. The signed payload is the JSON encoding of the transaction, the chain id and the nonce, concatenated; the chain id inside the signature is what makes a transaction from one chain useless on another.
There is no single submission endpoint. Each variant has its own route under /api/tx/, and most of them are operator-gated and signed with the node's key — on /api/tx/transfer the node is the signer the executor debits, and the request body's from is ignored. A prepare/submit-signed pair (/api/tx/deploy-element/prepare then /submit-signed, and the same for /api/tx/token-op) is the path for a transaction a client signed with its own key: the handler rebuilds the staged transaction, attaches the client's signature and public key, and refuses it if verify_signature fails.
client signs
│
▼
POST /api/tx/… ──▶ mempool (dedup by tx hash) ──▶ gossip to peers
│
▼
leader builds a block
header.state_root = its own current root
│
▼
PBFT: pre-prepare / prepare / commit
│
▼
commit certificate stapled into the header
│
▼
every node ──▶ verify ──▶ check state root ──▶ execute ──▶ persist
The gossip-and-consensus route is opt-in. --enable-p2p starts the libp2p swarm, which publishes the transport that flips transaction handling over: the node then never applies a transaction locally, it queues it, gossips it and returns "status": "pending", and the receipt exists only once the block carrying it is finalized. Without that flag — the default — the node takes the short path: one transaction, one block, signed by the node's own block signer and applied immediately.
The leader builds its proposal with an empty signature list. Those bytes are what every node hashes to form the commit digest, so the proposer must not put anything into the header that a peer cannot reproduce. Signatures arrive afterwards, as the PBFT commit certificate, and are appended by apply_committed_block. Appending does not change the digest, which is why validators can sign independently and in any order.
The transaction set
Seventeen variants, all in one enum. The executor matches on all of them exhaustively.
| Variant | What the executor does |
| --- | --- |
| Transfer | Debits the signer, credits the recipient wallet. Refuses a token that is soul-bound, tombstoned by an earlier dissolution, or carrying a network-property trait. A self-transfer writes nothing at all, because a naive debit-then-credit on one wallet would mint the amount. |
| DeployElement | Writes the element payload, its immutability class, declared traits, owner and home chain, then mints initial_supply to the signer. Refuses an id that already exists or was dissolved. |
| UpdateElement | Appends field updates to a side key, after checking the element's ImmutabilityClass permits every path in the update. |
| ExecuteContract | Checks the element's declared execution scope and trigger, compiles the element's bytes as WASM if they are not already cached, then runs method under the declared gas limit against live state. |
| SpawnChain | Derives an owner-scoped chain id, records the child's configuration, and mints a chain-hosting NFT to the owner. |
| CrossChainMessage | Records a fixed gas figure against the block budget and writes no state at all. Nothing in the node carries the message to the target chain; the HTTP handler submits it with an empty proof and the arm reads none of its fields. |
| ManagementUpdate | Owner-gated. Either records an execution-approval marker or appends role-change bytes to a side key. |
| MetamorphicOperation | Owner-gated on every source. Merge folds two or more distinct tokens into one deterministically derived id, crediting the sum and zeroing each source; Fractionalize splits exactly one token into two or more parts at derived ids, giving the division remainder to the first part. |
| BindToken | Owner-gated and one-shot. Records the binding and marks the token soul-bound, which is the flag the transfer path reads. |
| DissolveToken | Owner-gated. Burns the dissolver's holding, deletes the element, and writes a tombstone so the id can never be redeployed. |
| GovernanceVote | Writes yes or no under (proposal, signer). The voter field carried in the transaction is ignored — the signer is the voter. |
| ModuleSwapProposal | Records the proposed module and the size of its migration plan under the interface id. |
| StorageCommit | Stores a file manifest and maintains its folder listing in the same step, so a move is one transaction or none. File bytes never enter the block. |
| StorageDelete | Removes the manifest and unlists it from its folder. |
| StorageShare | Grants viewer or editor to another identity. Any other level is refused rather than stored. |
| StorageUnshare | Deletes the grant and its reverse index entry. |
| StoragePlanUpdate | Writes a quota plan. Signed either by the identity or by the chain operator, because usage has to be debited by the party providing the storage. |
The five storage variants are charged no fee: quota is bought as a tier, and a per-transaction fee would charge twice for the same bytes. On a chain whose fee config is not free-tier, every other transaction pays a base fee plus a per-byte fee, and ExecuteContract additionally pays the gas price times its full declared gas limit up front. The fee is deducted before the transaction runs and is not refunded when it fails. Child chains the node instantiates are built with FeeConfig::free(), which zeroes all three components, so on those chains nothing pays a fee.
Nonces are strictly sequential per wallet: a transaction is valid only at current + 1. The nonce is consumed on failure as well as success — but not when the signer cannot cover the fee, because that path returns before the increment.
The block header
Block N-1 Block N
┌──────────────────┐ ┌──────────────────┐
│ height │◀────────────│ parent_hash │
│ state_root ─────┼─ root as of │ state_root ─────┼─ root as of
│ transactions_root│ block N-2 │ transactions_root│ block N-1
│ receipts_root │ │ receipts_root │
│ chain_id, view, │ │ chain_id, view, │
│ sequence, ts │ │ sequence, ts │
│ proposer │ │ proposer │
│ signatures │ │ signatures │
└──────────────────┘ └──────────────────┘
The block hash is BLAKE3 over the height, parent hash, transaction root, state root, receipts root, chain id, view, sequence, timestamp and proposer. It does not cover the signature list, which is a commitment to the block rather than part of its identity. transactions_root is a binary Merkle root over transaction hashes with distinct leaf and node domain tags. Linkage is by parent_hash plus a strict height check: a block is accepted only at exactly tip + 1, against exactly the current tip hash, with a timestamp no more than 300 seconds ahead of the verifier's own clock, and with 2f + 1 distinct validator signatures that actually verify against the commit payload, where f = (n − 1) / 3 over the validator set. Genesis, at height 0, is exempt from the signature check.
The state root in the header is the root this block executes against — the state as of the parent — not the root the block produces. A proposer cannot know the post-state before executing, and it cannot execute before the block is committed without speculating on a round it may lose to a view change. Committing to the pre-state is the resolution.
Why the state root is checked first
Chain::apply_block verifies structure and the commit certificate, then compares the header's state root against its own current root, and only then executes. The ordering is the point.
If the comparison happened after execution, a node whose state had already diverged would execute the block onto a base the proposer never had, produce a root nobody else produces, and discover the problem only in whatever the mismatch happened to break later. Checking first means the node refuses to execute at all: apply_block returns an error, the caller logs the block as rejected, and the node stays at its current height instead of forking. Divergence surfaces at the very next block instead of compounding silently.
Why a failed contract rolls back
Contract execution is the one place inside a transaction where arbitrary code writes state and can then stop halfway — out of gas, or trapped. wasmtime::Store<T> requires T: 'static, so the executor cannot lend the VM a borrow of its state tree; it moves the tree into an owned adapter for the duration of one call and reclaims it afterwards. That adapter keeps an undo log: every storage write records the value that was there before, every balance write records the prior amount.
When the call returns an error, the executor replays that log in reverse — restoring prior values, deleting keys that did not exist before — and only then puts the state tree back. A reverted call leaves nothing behind, and the transaction gets a failure receipt while still paying its fee and consuming its nonce.
The same path is used when a token's own declared code fires automatically on transfer, so an executive token that traps cannot leave a half-applied write in state either.
What this design does not address
- Receipts are not committed to by the header. The
receipts_rootfield exists and is hashed into the block hash, but every code path that builds a block writes zero into it. The executor computes a real receipts root and returns it alongside the block's results; nothing puts it in the header. Execution results are therefore verifiable by re-execution, not from the header. - A zero state root disables the check for that block.
verify_state_roottreats a zero commitment as unverifiable and accepts it. That is what lets genesis and older blocks replay, and it means the check is only as strong as the producer's willingness to fill the field in. - The last block's output is never checked by a later block. Because the commitment is to the pre-state, a divergence introduced by the most recent block is caught by the next one. A chain that stops producing blocks stops checking.
- Rollback is per-arm outside the VM. Only the contract path has a general undo log. The other executor arms have none: they are written so that fallible work happens before any write, or so that a failing branch restores by hand what it changed. That is a property of how each arm is written rather than something the executor enforces.
- The mempool admits what it is given. Transactions entering from gossip are deduplicated by hash and nothing else; validation happens when a block executes. The mempool is an unbounded in-memory vector. The per-block transaction-count and byte limits in the chain configuration are reported over the API but are not consulted when a block is built or verified; the block gas limit is the one that bites, and it does so during execution, turning every transaction past the budget into a failure receipt rather than executing it.
- A few HTTP handlers still write balances directly. Chain spawning and domain registration debit the payer and credit the fee operator under the chain lock before the block is built, and refund the unspent remainder afterwards — writes that belong to no transaction, carry no receipt and appear in no block. A dev faucet does the same, though it refuses unless the node was started with
UNINET_ALLOW_INSECURE=1. On a multi-node chain a write made by only the node serving the request moves that node's state root away from its peers'. - Some variants record rather than settle.
CrossChainMessagewrites no state and is delivered nowhere.BindTokenrequires a non-empty acceptance proof but does not verify it cryptographically, because the transaction does not carry the target wallet's public key — any non-empty byte string satisfies it, which is why the arm is owner-gated instead.
How it composes
Ordering and execution are separate concerns. To the consensus engine a block is an opaque byte string it reaches agreement on; the executor is what turns those bytes into state. The commit certificate the engine produces is stapled into the header afterwards, which is what lets a node that was absent for the round verify the block later from the block alone.
ExecuteContract and the executive-trait triggers both run through the WASM runtime described in execution, under the same compile gates, gas accounting and rollback rules.
Balances live in the same trie as everything else, keyed by wallet and token — see assets and payments. A wallet address is BLAKE3 of a public key. The executor derives the acting address from the signer public key the signature check binds to the payload, never from a field the transaction body carries: the from field on Transfer and the voter field on GovernanceVote are both matched and discarded.
DeployElement records the signer as the element's owner; DissolveToken, BindToken, ManagementUpdate and both metamorphic operations then require that recorded owner. UpdateElement is not owner-gated — it is bounded by the element's immutability class and, where the element has a parent carrying an authority trait, by that parent's child-management policy. That is the on-chain half of authority and identity.
The trie can prove any key present or absent against a root without the rest of the state. The HTTP endpoint that exposes this does not accept a key: POST /api/storage/proof takes one of two targets — a file manifest or the caller's storage plan — and derives the key from the authenticated identity, so a caller can only prove entries in its own namespace. It proves against the node's live trie root, which is the root the next block's header will commit to, so a proof is tied to a header only once that block exists and the verifier obtains the root from somewhere other than the proof. When the walk would terminate on another identity's leaf, the node withholds the proof path and returns only its own verification result. What that does and does not establish is covered in the threat model.
What is not finished
Vote signature enforcement is opt-in at construction and the default single-node path does not verify, and there is no checkpoint or watermark collection, so a long-running validator accumulates message log without bound. Confirm which engine your deployment constructs before relying on it.