Chain
Blocks, transactions and state
One executor for every transaction, 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. The node's handlers write no chain state of their own: what they change, they change by submitting a transaction.
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.
POST /api/tx/submit takes a transaction of any kind that its signer built and signed, as a SignedTransaction in JSON. The signature is the authorization, so it needs no session: the node refuses one over 512 KiB or whose signature does not verify, and passes the rest to the chain it is signed for, when the node hosts that chain. The other routes build the transaction for their caller. The single-call ones under /api/tx/ are operator-gated and signed with the node's key: on /api/tx/transfer the node is the signer the executor debits, and a from in the request body is ignored. A prepare/submit-signed pair (/api/tx/deploy-element/prepare then /submit-signed, and the same for token-op, update-element, execute-contract and spawn-chain) is the path for a transaction a user signs with their own key: the node holds the transaction it built, attaches the user's signature and the public key on the user's identity, and refuses it if verify_signature fails. A user's transfer has a pair of its own (/api/transactions/prepare and /submit-signed), and other kinds are built by the routes for their subjects, such as /api/dns/register for a name and /api/storage/commit for a file.
client signs
│
▼
POST /api/tx/… ──▶ mempool (admission rules, dedup by tx hash)
│
┌─────────────────┴─────────────────┐
▼ ▼
signed by the node: signed by a user, routing on:
gossiped to peers held in the mempool, sent over a
circuit to another validator,
which gossips it as its own
│ │
└─────────────────┬─────────────────┘
▼
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 admits the transaction to its mempool, sends it on 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 exception is a node outside the validator set that runs the public swarm (public_p2p): it makes no blocks and keeps no mempool, so it checks what its users submit against the mempool's rules and passes it to the validators over the public swarm.
The mempool checks every transaction it takes, whether it arrives by submit or by gossip (bin/uninet-node/src/mempool.rs). The signature must verify against the signer's key, the transaction must be signed for this chain and be at most 512 KiB serialized, and its nonce must be one of the next 256 its signer can use, since gossip can deliver a signer's transactions out of order. A signer may have 256 transactions pending, and the pool holds at most 8,192 transactions and 32 MiB. A refused submit returns the rule it broke, and nothing is gossiped. A leader puts at most 512 KiB of transactions in a block, each signer's in nonce order, and holds a transaction past a gap in its signer's nonces out of its proposals for 3 s after it arrived, so the earlier one can catch up.
A transaction the node signs with its own key names the node anyway, and the node gossips it to its peers. What a user signed, a validator with cross-node routing on (its unp_routing setting) does not gossip. It sends the transaction over a circuit to another validator, which takes it into its mempool and gossips it as its own (bin/uninet-node/src/tx_circuit.rs), so the validators that see it gossiped cannot tell which node it came in through. The first node keeps it in its own mempool meanwhile, held (TxPool::admit_held): pending for everything the pool is asked about its signer, and out of the node's own proposals until it comes back by gossip. What has not come back 5 s after the other validator took it, the node gossips itself, as it does at once when no circuit can be drawn or the other validator does not take it. Setting unp_routing.tx_circuits to false turns this off. A hidden site's bind is the exception: the node never gossips it, sends it to up to three validators in turn and drops it if none of them passes it on, and refuses it when circuits are off or no circuit of two relays or more can be drawn.
Gossip is sent once and may reach nobody. So while a transaction a node gossiped is still pending, the node gossips it again: first 1 s after it sent it, then at doubling gaps up to 60 s (TxPool::due_again). A transaction it only heard, or one it holds, it never sends again.
The leader builds its proposal with an empty signature list. Those bytes are what every node hashes to form the commit digest, and a node holding the finished block gets the same bytes back by emptying its signature list, so a validator refuses a proposal that carries signatures, or whose bytes are not the block's own encoding (Chain::check_proposal). 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
All in one enum, which the executor matches exhaustively. The table lists the first seventeen. The enum has grown since: identity registration, names, sub-wallets, access records, node operators, stakes, relays, validator votes, evidence of a double vote, changes to a child chain, organizations, changes to a storage plan or its tier, batches of file manifests, and the NFT rules' deploys, collection mints, NFT operations and merge recipes (see elements and tokens) are transactions too, described on the pages for those subjects.
| Variant | What the executor does |
|---|---|
Transfer | Debits the signer, credits the recipient wallet. Refuses a token that is soul-bound (bound, or with a binding condition that has been met), tombstoned by an earlier dissolution, or carrying a network-property trait. A self-transfer moves no balance, 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, was dissolved, or is an organization's. |
UpdateElement | Appends field updates to a side key, after checking that the signer owns the element or may write every field the update touches, and that the element's ImmutabilityClass permits every path in it. |
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 | Primary chain only, and the signer is the owner. Derives an owner-scoped chain id, records the child's configuration and the record its hosts start it from (its validators, drawn from the primary's set, and its status), 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 | Refused once the NFT rules apply. Before them: owner-gated on every source, acting on the signer's own holdings. 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 | Refused once the NFT rules apply. Before them: owner-gated and one-shot, recording the binding and marking 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 | Retired, and always refused: a whole plan written back could undo a change made in between. A plan now changes through StoragePlanChange, which names only what moves (usage, recorded by the chain operator or the validator that holds the bytes, or the replication topology), and StorageTierChange, which moves it to another tier and charges the owner the tier's price for an upgrade the owner signs. |
The storage variants are charged no fee: quota is bought as a tier, and a per-transaction fee would charge twice for the same bytes. Opening a sub-wallet, a child chain adopting the validator set its owner chose, and evidence of a double vote are free too. 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. A child chain charges the primary chain's schedule, in its own token.
A fee is not destroyed. Every chain keeps its fees in a pool, and the next block pays the pool out to the validators that signed the block before, as its last_commit names them: after the share of a treasury, if the network's genesis names one, the pool is divided by the number of validators, each one that signed takes a share, and a share nobody earned is burned, so a proposer gains nothing by leaving a signer out. On the primary chain a share goes to the wallet of the operator identity bound to the node, or to the node's own wallet.
A block also applies rules no transaction carries, before its transactions run. On the primary chain it schedules a validator proven faulty out of the set, to leave 8 blocks on, and waits while another change of the set is pending or the set is down to 4 validators; on the first blocks of a day it charges every chain that reserves resources for the day behind it and pays its hosts, and a chain whose owner cannot pay is frozen by that same rule; and it records which validators signed its parent. On every chain it then pays out the fee pool. These are part of executing the block, so every node applies them alike. The proof of a fault is a transaction of its own: evidence of a double vote, which burns half the UNE staked behind the validator and marks the validator to leave the set. Nobody is paid for reporting it.
Nonces are strictly sequential per wallet: a transaction is valid only at current + 1. The nonce is consumed once the transaction runs, whether it succeeds or fails. A transaction that fails before it runs consumes none, because those paths return before the increment: one that fails the checks made first (its chain, its signature, the nonce itself, and for a few kinds more, such as whether a transfer's signer holds the amount and the fee), one the block has too little gas left for, and one whose signer cannot cover the fee. A transaction the node signs with its own key on the primary chain takes the lowest nonce past the last one the chain has used that none of the node's own transactions holds: none pending in its mempool, and none it gave in the last 30 s to a transaction still on its way there (AppState::sign_transaction). It is not taken from a counter, so a transaction that used no nonce leaves no gap behind it.
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 │
│ last_commit │ └─────────▶│ last_commit │
└──────────────────┘ └──────────────────┘
The block hash is BLAKE3 over the height, parent hash, transaction root, state root, receipts root, chain id, view, sequence, timestamp and proposer, and over the parent's certificate when the block carries one. 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; a transaction's hash covers its signed payload and its signer's key, so two signers' identical transactions at the same nonce do not share one. 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, dated no earlier than its parent and no more than 300 seconds ahead of the verifier's own clock, and with signatures from quorum_threshold(n) distinct validators of the set for that height that actually verify against the commit payload: ceil((n + f + 1) / 2) of the n validators, where f = (n − 1) / 3, which is the textbook 2f + 1 at 4, 7 or 10 validators and more at other sizes. Genesis, at height 0, is exempt from the signature check.
A header also carries its parent's commit certificate, as its proposer holds it (last_commit). Each node keeps the first quorum it receives in a block's own signature list, so nodes can disagree about who signed a block until the next block settles it, inside the bytes every validator votes on. A validator refuses a proposal above height 1 that carries no valid certificate of its parent. On the primary chain that certificate is the record of which validators took part, and on every chain it decides who the fee pool is paid to.
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. A validator makes the same comparison before it votes for a proposal (Chain::check_proposal), so it never signs a block that commits to a state other than its own.
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. The adapter remembers the state root the call started from, and the contract's storage and balance writes go to the trie as they are made.
When the call returns an error, the adapter makes that starting root current again (StateTree::revert_to), which discards every write the call made in one step, rewriting no value, and only then does the executor put 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, so that blocks written before headers carried the commitment still replay. Genesis does not rely on it: it is built from its configuration rather than applied, and commits to the root that configuration creates. Validators do not extend the exemption to a new block: each votes for a proposal only when its root equals the validator's own, zero included (Chain::check_proposal). - The most recent block's output is checked only by the next block. Because the commitment is to the pre-state, a divergence introduced by the most recent block is caught by the next one. On the primary chain of a network, a leader with nothing to propose makes an empty block once the tip is 60 seconds old (
idle_block_secs), so an idle primary chain is still checked about once a minute. A child chain makes no empty blocks, and a single node makes a block only for a transaction, so a chain that stops producing blocks stops checking. - Rollback is per-arm outside the VM. Only the contract path has a general rollback, back to the root the call started from. 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 checks form, not outcome. It refuses a transaction that is badly signed, signed for another chain, too large or at a nonce its signer cannot use, and it bounds what one signer and the whole pool can hold. Apart from evidence of a double vote, which it takes only while the chain would still act on it, whether a signer can pay and whether a transaction succeeds is decided only when a block executes it. The pool is kept in memory only, so a node that restarts comes back with it empty. 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: a leader keeps a proposal to 512 KiB of transactions by a rule of the node's own, which followers do not check, and the block gas limit bites during execution, turning every transaction past the budget into a failure receipt rather than executing it.
- Payouts and daily charges have no receipt. The fee payout and the daily hosting charge move balances at the start of a block, with no transaction and no receipt. The chain keeps running totals instead: what each node and wallet earned, by kind, and what each chain's owner paid.
- 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 signer's wallet address is BLAKE3 of its public key. The executor derives the acting address from the signer public key the signature check binds to the payload, not from a field the transaction body carries: the from field on Transfer and the voter field on GovernanceVote are both matched and discarded. Two kinds act from another wallet: a SubWalletTransfer spends from one of the signer's own sub-wallets, derived from its key and an index, and an organization's act runs from the organization's wallet once the organization's rules accept it from the signer.
DeployElement records the signer as the element's owner; DissolveToken on an older token, ManagementUpdate, the older BindToken and metamorphic operations, and UpdateElement then require that recorded owner, while an NFT's operations are its holder's. UpdateElement also takes another signer, for the fields the chain's access records let that signer write, and every update stays bounded by the element's immutability class. Where an element's parent carries an authority trait whose child-management policy takes control, only the parent's owner may do these things to it, except BindToken, which the element's owner may still sign. An element with no recorded owner gives no signer an owner's rights. 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 node serves proofs of particular records, never of a raw key the caller names. 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 any entry on the path is not the caller's own, the node withholds the path, which would carry that entry's value, and reports its own verification result in its place. The proofs of public records (who holds a domain at GET /api/dns/proof, the relay list, the validator set's changes and the route directory) are anchored at the state root of a block's header instead, which a client checks against that block's certificate (GET /api/blocks/:height/signed). Because a header commits to the state before its block, a change is provable only from the block after the one that carried it. A node keeps the state of its last 128 blocks provable (state_retained_roots) and answers 410 for an older one. What that does and does not establish is covered in the threat model.