Skip to content
nullcryptodevPublic

About

"Authority rotates, the chain doesn't." - Proof of Rotating Authority (PoRA), immediate finality, sparse Merkle state with historical proofs, tokens and NFts, AMM, limit orders, two-key validators, epoch rewards.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

37 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Clarity - Authority rotates, the chain doesn't.

Known Tests Net Discord npm wallet npm wallet

Documents

Table of Contents

What it is

Clarity is a single-chain, Byzantine-fault-tolerant proof-of-stake network with a native currency ($CLRTY), a general-purpose token system, an automated market maker, a price-time-priority limit-order book with an in-process matching engine and AMM routing for Active orders, a first-class NFT system with optional on-chain reward schedules, validator rewards with a pot mechanism, mutable validator signing keys with full history, and a growing feature set around staking, validator rotation, and on-chain governance of validator sets.

It's not a direct fork of anything. The block format, consensus protocol, state model, matching engine, Active-order routing, NFT model, key-rotation machinery, and reward math are original. It uses well-known primitives (Ed25519, X25519, Blake2b, SHA-512, XChaCha20-Poly1305) from Monocypher, the Argon2 reference implementation for keystore KDF, the BLAKE2 reference code, and a project-internal Keccak implementation verified against the official Keccak test vectors.

The project is at the code-complete, pre-launch stage. The daemon builds, the full test suite passes, and the end-to-end path (from transaction submission through consensus through block application through matching and Active-order routing through NFT mint/wrap/transfer/burn and reward accrual through state persistence through restart) has been exercised on a live two-validator regtest network. The RPC surface is complete and tested. The wallet layer (keystores, HD derivation, signing, address encoding) is complete and tested, and there is an interactive CLI wallet client. The P2P layer has authentication, session encryption, sync, block relay, transaction relay, proof serving, per-peer and node-wide rate limiting, and periodic refresh. A Prometheus-compatible metrics endpoint exposes node, P2P, mempool, storage, reward, and consensus state. It has not been deployed to a public network.

The architecture

Eleven main modules, from bottom to top:

Crypto           hashes, signatures, AEAD, key exchange, key types
Common           encodings (Base58, Base64, hex), CRC32, JSON, varint, rate limiter, wire codec
Serialization    binary, KV-binary, JSON serializers
State            MDBX storage, compressed radix Merkle trie, state access, proofs, historical reads
Core             blocks, transactions, execution, block processing, rewards,
                 order book, matching engine, Active-order AMM router,
                 validator key-rotation history, equivocation proofs,
                 NFT records, NFT collections, NFT reward schedules
Consensus        BFT state machine, proposer selection, message encoding
P2P              TCP transport, peer management, message framing, auth, session encryption,
                 sync, relay, proof serving, rate limiting
Node             assembles everything into a running daemon
RPC              JSON-RPC 2.0 server, dispatcher, method handlers, encoders, metrics endpoint
Wallet           keystores, HD derivation (BIP39/SLIP10), signing, addresses,
                 NFT transaction builders

Companion binaries:

clarityd            the daemon
clarity_wallet      client wallet
address             keystore utility: create, import, inspect
                      (derives both the reward key and the consensus key
                      from one mnemonic, prints any network's encoding)
genesis_hash        computes and verifies the pinned genesis hash per network
transaction_signer  offline wallet transaction signer

Build

# Clone repo
git clone https://github.com/nullcryptodev/Clarity

# Enter cloned dir
cd Clarity

# Remove bugged external
rm -rf external/json

# Re-add bugged external
git clone --branch v3.11.3 --depth 1 https://github.com/nlohmann/json.git external/json

# Make new dir
mkdir build

# Enter build dir
cd build

# Run cmake command
cmake ..

# Run make command
make

# Enter built apps dir
cd src/Apps

Tests

You can launch clairty_tests with the gtest filter: ./clarity_tests --gtest_filter='Consensus*' to run tests on individual modules.

# Follow steps from Build, until cmake command

# Run cmake command
cmake -DBUILD_TESTS=ON ..

# Run make command
make

# Enter tests dir
cd src/Tests

# Run test program
./clarity_tests

Consensus: how blocks are produced

Protocol

A variant of BFT with three phases per round -- propose, prevote, precommit. When a quorum of precommits forms on a block, it commits. The protocol tolerates f = (n - 1) / 3 Byzantine validators, with a quorum of (2n / 3) + 1 (integer division, i.e. the floor). So four validators tolerate one fault (quorum 3); seven tolerate two (quorum 5); twenty-one tolerate six (quorum 15).

Validators (N) Quorum
1 1
2 2
3 3
4 3
5 4
6 5
7 5
11 8
21 15

Proposer selection

proposer = active_set[(height + round) mod active_set_size]. Deterministic, rotates with every height and every round. The proposer builds a block, broadcasts it, and the other validators vote.

Rounds

If a round fails to reach quorum (proposer offline, network partition, timed-out proposal), the round number increments and a new proposer takes over. Timeouts scale exponentially with the round number, capped at a maximum. A proposal for a future round is queued and delivered when the local round advances, so a validator that's briefly behind doesn't drop the round's only proposal.

Locking

A validator locks on a block when it precommits to that block after seeing a prevote quorum (polka) for it. The lock is set at precommit time, not prevote time, so a validator that prevoted X but never saw a polka for X is not committed to X and will prevote Y in the next round if Y is proposed.

A validator that saw a polka for X after entering precommit does not record it, and so does not prevote X in the next round. That is a rare case that delays round-crossing recovery but does not break safety: precommitting X and later precommitting Y would require a higher-round polka for Y.

Once locked, at prevote time the validator prevotes the locked block unless a newer polka (from a round strictly greater than the lock round) supersedes the lock. At precommit time it precommits the block with a polka this round, or the locked block if no new polka has formed. The lock survives round transitions within a height and is cleared only on a height change.

Finality

One block per round. Each committed block is final -- no fork choice, no longest-chain rule, no reorgs. A block that reaches precommit quorum is the canonical block at that height.

Validator set

Bounded between 11 and 100. Rotates at epoch boundaries (every 60 blocks, ROTATION_INTERVAL). The rotation is sized to min(ceil(n / 21), n - bftQuorum(n)), a small enough fraction that rotation can never break quorum. At n=100 this gives 5, not 33 -- the safety cap is n - bftQuorum(n) and bftQuorum(100) = 67, so the safety bound is 33; the epoch-proportional bound is ceil(100/21) = 5, and the smaller of the two wins.

Seed validators

Two seed validators are declared in every network's genesis (mainnet, testnet, regtest). Each seed is derived from a single BIP-39 mnemonic, which produces two independent keys:

  • Reward address, at m/44'/9000'/0'/0'/0', a bech32m address. Encoded per network (clrty1..., tclrty1..., rclrty1...), same underlying pubkey for all three.
  • Consensus key, at m/44'/9000'/0'/2'/0', a raw 32-byte Ed25519 key. Same value on every network -- it carries no HRP.

The two keys are independent. Rotating one does not affect the other. Both are recoverable from the same mnemonic, so backing up the words backs up both.

Seeds are never removed from the active set by normal rotation, offline removal, or unhealthy-culling. This is the trust anchor for bootstrap -- the seeds are expected to be operated by the project itself. The mainnet and testnet genesis configs are defined and pinned but the networks are not live.

Dry-run vs. finalized validation

The block processor distinguishes between simulating a block (the proposer needs to know what state root a candidate block would produce, but the block's state root and quorum signatures aren't populated yet) and applying a finalized block. BlockContext::dry_run skips structural header checks, quorum verification, and the state-root comparison; the non-dry path runs them all. This split is load-bearing -- the consensus proposer relies on it, and the test suite exercises both paths.

A subtle invariant: every state change that touches the SMT must happen on both paths. Only writes to the meta table (TBL_META) and to the non-SMT index tables may be gated on !ctx.dry_run. The dry-run path aborts its transaction, so the change is discarded; the apply path commits it. If a change is gated, the two paths produce different state roots and the proposer's block is rejected on apply. This was a source of a hard-to-diagnose bug where blocks 1 and 2 were committed by consensus but rejected by every node that tried to apply them.

The matching engine, the Active-order router, the consensus-key history maintenance, and the NFT epoch-boundary reward accrual all obey this invariant. Their book rows, fill-history rows, order-sequence counter, pool reserve updates, key-history writes, and NFT collection escrow decrements all go through the SMT unconditionally, and the MatchingSimulateAndApplyProduceSameRoot, MatchingRootIdenticalAcrossFreshInstances, and NftReward_SimulateAndApplyProduceSameRoot tests are the canaries that catch a future gated write.

Commit failure handling

The on_block_committed callback returns a bool. BftConsensus::tryApplyCommit checks the return value, and on failure the engine retries the apply in place, with a linear backoff. Up to max_commit_retries retries are attempted. Each retry is driven by pollTimers, which compares deps_.now_ms() against next_commit_attempt_ms_.

A failed apply does not advance the round. The block already has 2f+1 precommits; advancing the round would re-propose a block every honest validator is locked on, and would push a validator with a persistent apply failure toward emergency rotation. The engine retries the same block until it applies.

If every retry fails, the engine calls haltConsensus(reason): running_ becomes false, halted_ becomes true, the round timer is cancelled, and the on_consensus_halt callback fires once. on_height_advanced does not fire, the WAL is not truncated, and pending_height_ is not set. The engine stays halted until the node calls start() again. halted() is a public accessor.

The other way out is noteChainHead(Height). If a relay or sync path applied the block while consensus was retrying, the node calls noteChainHead to tell the engine the chain has already moved; the next pollTimers abandons the retry and enters the reported height directly. noteChainHead is lock-free and safe to call from inside any callback, including on_block_committed.

Before this change, the callback returned void, and a failed apply still fired on_height_advanced and set pending_height_. The engine would march forward to height N+1 while the chain head stayed at N, and every subsequent block would fail the same way. The height would appear to advance in the consensus engine's state while the on-chain height stayed stuck.

State: how the chain is stored

Storage

MDBX, a memory-mapped key-value store. Thirty-nine tables across four groups:

  • Live SMT — TBL_SMT_NODES, TBL_SMT_LEAVES. Content-addressed; a re-write of the same node upserts the same row.
  • Historical SMT — TBL_SMT_NODES_HIST, TBL_SMT_LEAVES_HIST, TBL_SMT_NODES_BY_VER, TBL_SMT_LEAVES_BY_VER. Written by every tree update at version != 0. Pruned inside the block-commit transaction.
  • Entity tables — accounts, tokens, balances, validators, orders, pools, positions, NFTs, receipts, blocks, transactions, mempool, consensus WAL, meta.
  • Index tables — the "by owner", "by token", "by pair", "by producer", "by type" indexes the RPC and metrics layers read. Node-local; can be rebuilt from the entity tables.

See the comment on StateDB in src/State/StateDB.h for the exact TableId enum and key layouts.

For v1, all of these live in a single MDBX database file at <data_dir>/state. Chain data — block indices, block bodies, receipts, the transaction index — shares the same file as state data. There is no separate chain DB directory. This is a storage-layout decision, not a consensus-relevant one: the atomic commit discipline that writes state, block index, and head together in one transaction relies on the two being in the same MDBX instance.

State commitment

A compressed radix Merkle trie. Keys are 32-byte hashes derived from account addresses, token-balance keys, validator IDs, validator key-history IDs, NFT ids, NFT collection ids, and global state names. Values are the serialized records those identifiers point at. The tree's root is the state root, which is written into every block header. Any two nodes with the same state produce the same root.

The tree is path-compressed: an internal node is a branch with a shared bit-prefix and exactly two children, distinguished by the bit immediately following the prefix. Chains of single-child nodes are collapsed into a single branch by storing the shared prefix on the branch itself. This is the classic Patricia-trie layout, and it is what gives the tree its O(log N) height for N leaves instead of the fixed 256 levels the previous design used. A tree with a million leaves is about twenty levels deep; a tree with a thousand leaves is about ten.

Node kinds. A leaf is (key, value_hash); its hash preimage is "leaf" || 0x00 || key(32) || value_hash(32). A branch is (prefix_len, prefix_bits, left_child, right_child); its hash preimage is "brch" || 0x01 || prefix_len(BE16) || prefix_bits_padded || left(32) || right(32). The empty subtree at a given remaining depth has a precomputed default hash "dflt" || 0x02 || depth(BE16), and a branch whose two children are both the default for their depth is itself the default for the branch's depth. This is what keeps the tree sparse: an empty subtree is represented by a single hash, not by an exponential number of default nodes.

Storage cost. Each tree update writes O(log N) nodes for N live keys, not a fixed 256. At 1,000 live keys a single update writes ~10 nodes; at 1,000,000 keys, ~20. The historical tables grow with state changes, not with block count: a subtree that did not change between two versions is stored once, at the version where it first appeared. The default retention window is 2048 blocks on mainnet, 512 on testnet, 64 on regtest (see MAINNET_SMT_HISTORY_BLOCKS). With a 2048-version window and a busy block touching K keys, the steady-state historical-table cost is roughly K * log N * 2048 rows. This is the number to watch when sizing disk.

The two invariants that make the tree canonical. First, every branch consumes at least one bit — the branching bit — so the tree height is bounded by the key length and by the number of distinct keys. Second, a branch is never stored with a single non-default child; when a child becomes default after a delete, the branch collapses into the surviving child with an extended prefix. These two invariants are what make the tree shape a pure function of the key set: two nodes with the same keys produce the same tree regardless of the order in which the keys were inserted. The tests ManyInsertsAreOrderIndependent and ApplyBatchMatchesSequential cover this.

State Access

A StateAccess object wraps the DB and provides typed getters and setters for accounts, token balances, token metadata, validators, validator key histories, orders, AMM pools, AMM positions, NFTs, NFT collections, receipts, and global state. Reads and writes go through the tree so every change updates the root.

StateAccess exists in two modes. Txn-bound mode is used by the block processor and consensus paths: every read and write goes through a single MDBX write transaction that the caller opened and owns. Autocommit mode is used by the RPC layer, the metrics handler, and the various Node helpers: the StateAccess owns a single MDBX read transaction for its lifetime, and read methods route through it. The autocommit mode is what makes a tree walk — dozens of individual node lookups — cheap in reader-slot terms: the walk holds one reader, not one per lookup.

The matching engine, the Active-order router, the key-history maintenance paths, and the NFT reward accrual are txn-bound callers: they run inside the block processor's write transaction, and every read they make (getOrder, forEachBookLevel, getAmmPool, getValidatorKeyHistory, getNft, getNftCollection, forEachNftCollection) and every write they make (putOrder, deleteOrder, addToOrderBook, removeFromOrderBook, indexFill, putAmmPool, putValidatorKeyHistory, putNft, putNftCollection, deleteNft) goes through the same StateAccess that the executor uses. This is why a fill's balances, records, book rows, fill-history rows, pool reserve changes, key-history updates, and NFT record and escrow changes all commit or abort together with the block.

Read transactions

MDBX permits multiple read transactions across threads but exactly one read transaction per thread. A second mdbx_txn_begin(RDONLY) on a thread that already has a read transaction open returns MDBX_BUSY. This is a hard constraint that shapes the code.

Every read path in the state layer takes one of two forms:

  • Held transaction. StateAccess in autocommit mode owns a StateDB::ReadTxn for its lifetime and routes all reads through it. The compressed tree is bound to the same transaction via setReadTxn, so its own internal walks also go through it. A single StateAccess used for a chain of reads — the entire block-application state transition, or an RPC handler that scans a range — holds exactly one reader slot.
  • Per-thread fallback. StateDB::rawGet, StateDB::forEachEntry, and the other raw accessors check a thread-local handle (tls_read_txn_) before opening a transaction. If a read transaction is already open on this thread, they reuse it. This is what makes a nested read from inside a forEach* visitor safe: the visitor is called from within the outer iteration's read transaction, and any get/has/forEach the visitor performs reuses that same transaction rather than trying to open a second one.

The second form is a safety net for code paths that predate the read-transaction binding. New code should prefer the first form — an explicit ReadTxn bound into the StateAccess — because it makes the transaction's lifetime explicit in the call graph.

Write discipline

MDBX also refuses to open a write transaction on a thread that has a read transaction open. This is the same constraint as above, applied to the write path.

Every write method on StateAccess calls releaseReadTxnIfHeld() before delegating to the underlying rawPut/rawDel. This destroys the held ReadTxn (if any) and unbinds it from the tree. The next read after the write transparently opens a fresh read transaction. This makes a StateAccess that interleaves reads and writes — which is most of the block-application path — safe without the caller having to know about the ordering.

A related invariant that emerged during NFT development: putX methods release their held read transaction before writing. A putX may read the old record — putAccount reads it to maintain staker_since_height and total_staked; putOrder reads it via indexOrderRow to write the index rows — but it must call releaseReadTxnIfHeld() before its first write, and it must not depend on a read that the caller has already performed. The failure mode this rule prevents is not a silent drop; it is a hard requireNoOpenReadTxn throw from MDBX when a write is attempted on a thread that already has a read transaction open. putNft was changed to a pure write not because reads are forbidden, but because the old version read the caller's record (a read the caller had already done) and re-read it inside the write, which doubled the read-txn acquisition and could trip the guard under concurrent access. The fix was to move the read to the caller and expose unindexNft as a separate call. putAccount and putOrder read their own record, which is a different pattern and is safe.

The write paths that call releaseReadTxnIfHeld() include putAccount, deleteAccount, putTokenBalance, deleteTokenBalance, putToken, putTokenSupply, putValidator, deleteValidator, putValidatorByAddress, deleteValidatorByAddress, putValidatorKeyHistory, putOrder, deleteOrder, addToOrderBook, removeFromOrderBook, refundOrder, deleteRestingOrder, indexFill, putAmmPool, deleteAmmPool, putAmmPosition, deleteAmmPosition, putPositionIndex, deletePositionIndex, putReceipt, putGlobal, putMetaImpl, putRaw, deleteRaw, commit, putNft, deleteNft, putNftCollection, deleteNftCollection, unindexNft, and the four low-level row helpers putSentinel/putU64Row/putBlobRow/deleteRow.

Proofs

Inclusion and non-inclusion proofs can be generated for any key. A proof carries the key, a terminal descriptor, and a variable-length list of siblings. Verification recomputes the root from the proof and compares. Proofs are a pure-function construction: verifyProof(expected_root, proof) performs no DB access and no signature checks, so a caller that holds only a state root can verify a proof without trusting the server that produced it. Proof generation is exposed over P2P via the GetProof/Proof message pair (see Proof Serving), over RPC via clrty_getProof, and in-process by tests and diagnostic tooling. NFT records and collections live in the tree under their own domain-separated keys, so proofs work on them exactly as they do on accounts and orders.

The four terminal kinds. Because the tree is compressed, a proof's walk can terminate at one of four things: an inclusion at a leaf whose key matches; a default subtree (the key is not in the tree at that subtree); a different leaf whose key is not the proven key; or a divergent branch whose prefix the proven key does not share. Each of these is a distinct case in the proof's wire format, and each carries exactly the fields the verifier needs to reconstruct the terminal hash. An earlier design omitted the terminal descriptor and silently produced invalid proofs for the divergent-branch case; the current format carries it explicitly, and the four-kind enum is part of the proof's stable wire contract.

Why the sibling list is variable. A compressed tree's proof has O(log N) siblings, not the fixed 256 the uncompressed tree required. Each sibling carries its branch's prefix (length and bits) plus the sibling subtree's hash. The verifier walks the siblings from deepest to shallowest, combining current with each sibling to reconstruct the branch at that level. The depth at which each sibling sits is derived from the sum of ancestor prefixes plus branching bits; the wire format does not carry it explicitly.

Proofs against historical roots. proveAtRoot(tree, root, key) produces a proof against an explicit root, which is what lets a node serve proofs against historical state, not just the current head. A proof's value and its sibling list must come from a single walk from a single root — reading the leaf separately from the current tree would produce a proof whose value and siblings disagree whenever the requested root is historical.

Versioning

The tree saves its root at every version number, and stores a historical row for each node and leaf it writes at a non-zero version. rootAtVersion(n) retrieves a historical root, and getAtVersion(key, n) retrieves a key's value as it was at version n. Typed historical reads are exposed on StateAccess as getAccountAtVersion, getValidatorAtVersion, and getGlobalAtVersion; the raw primitive is getRawAtVersion, which takes the same tree keys the current-version getters use. Historical reads work on NFT keys as well, so a block explorer can ask "who owned NFT 42 at height 1000?" after the NFT has changed hands.

Version 0 is the bootstrap version — the state before any block has been applied — and is not stored historically. The chain's genesis state is applied at version 0 and never queried historically, so skipping it keeps the historical tables from accumulating rows that nothing reads.

Historical storage is content-addressed and deduplicated: a node whose subtree did not change between two versions is stored once, at the version where it first appeared, and remains reachable from every later version's root. In practice this means the historical tables grow with state changes within the retention window, not with block count or with the size of the live tree. A node that prunes aggressively (small smt_history_blocks) can serve proofs against the current root and any version inside its window; a node that keeps everything can serve proofs against any version since genesis. The two nodes agree on consensus.

Historical reads are bounded by the retention window. The StateAccess layer knows the window and rejects reads outside it before touching the DB, returning a typed VersionUnavailable. The window is set once at node startup from NodeConfig::smt_history_blocks and applies to every StateAccess the node constructs — see Historical pruning.

Historical pruning

The tree retains historical rows for a bounded number of versions. smt_history_blocks (per-network default: 2,048 on mainnet, 512 on testnet, 64 on regtest) is the size of the retention window. Rows for versions strictly older than current_height - smt_history_blocks are deleted from all four historical tables — smt_nodes_hist, smt_leaves_hist, smt_nodes_by_ver, smt_leaves_by_ver — inside the block-commit transaction.

Pruning is atomic with the commit. The block's state changes, block index, chain head, receipts, and the historical prune all land in the same MDBX write transaction. Either the entire block and its prune commit together or neither does. A crash between commit and prune is not possible; the window is exact.

Pruning is per-network, not per-chain. Two nodes with different retention windows still agree on consensus, because pruning only affects historical proof serving and historical state reads, both of which are local queries. A node that prunes aggressively can still serve proofs against the current root and any version inside its window; a node that keeps everything can serve proofs against any version since genesis.

Why the window is small. Because consensus is BFT with immediate finality, there are no reorgs and no fork choice that needs historical state. Historical queries are a block-explorer convenience and a light-client-proof feature, not a consensus requirement. A node that wants deeper history than the default window runs an indexer that scrapes blocks and maintains its own state. The default values are chosen so the historical tables stay bounded on typical hardware without operator tuning.

The live tables are not pruned. TBL_SMT_NODES and TBL_SMT_LEAVES are content-addressed: they hold one row per distinct node hash. When the tree changes shape — a split, a collapse, a rewrite — the old hashes remain in these tables even though they are no longer reachable from the current root. This is a known cost of content-addressed storage, and it is bounded by the total number of distinct subtrees the chain has ever produced, not by the number of writes. For a chain that grows its live state monotonically, this cost is small next to the live tree itself; for a chain that churns its state heavily, it is a candidate for future garbage collection. Neither is a v1 concern.

Transactions: what users can do

Every transaction is signed with Ed25519 by the sender, has a nonce for replay protection, a chain ID, a fee, and a type-specific payload. There are roughly twenty-five transaction types:

Native transfers

Send CLRTY from one address to another. The from balance decreases by amount + fee; to increases by amount. Nonce bumps on success, stays on failure.

Token operations

Create a token with a name, symbol, decimals, max supply, optional royalty, and optional fingerprint (for bridged tokens). Mint tokens up to the max supply (creator only). Burn tokens (reduces the tracked supply). Transfer tokens. Update token metadata (creator only -- changes name/symbol/royalty but not max supply).

Staking

Opt-in and opt-out of auto-staking. The auto-stake threshold determines when a balance becomes staked. Staked accounts are eligible for staking rewards at epoch boundaries.

Validator operations

Register as a validator (requires a minimum stake). Unregister -- starts an unbonding countdown: the validator is removed from the active set immediately, but its record, address index, key history, and stake persist for UNBONDING_PERIOD (120 blocks, two rotation intervals) before being released to the owner. The validator cannot re-register while a pending unbond is in flight, and its record remains slashable for the entire window. Unregistration is forbidden for seeds, forbidden if the active-set removal would drop the set below the minimum, and rejected if a pending unbond is already in progress. Update reward address -- changes the address where block rewards are paid, with no effect on consensus participation. Registration writes a validator-by-address index entry that maps the reward address to the validator ID. Rotate consensus key -- see Rotating the Consensus Key.

AMM operations

Create a pool for a pair of tokens (or a token and native CLRTY). Add liquidity (mints LP position). Remove liquidity (burns the position and returns the reserves) -- removing the last LP's full share closes the pool: the record is deleted from state, and the pool no longer exists for swaps or further liquidity additions. Swap through the pool with a constant-product formula and a fee.

Limit orders

Two modes: Passive and Active.

A Passive order rests in a price-time-priority book and is matched by the in-process matching engine. It has a directional pair (sell_token, buy_token), a sell amount, a minimum buy amount (which encodes the limit price as min_buy_amount / sell_amount), an optional expiry height, and up to eight conditions. Creating the order takes a lock on the sell amount in the same transaction. The order is then matched against the resting book: the incoming order is the taker, the resting orders are makers, and each resting order is executed at the maker's price.

An Active order routes against an AMM pool instead of resting in the book. It has a pool_id naming the pool, a (sell_token, buy_token) pair matching that pool's two tokens, and the same limit-amount fields as a Passive order (sell_amount, min_buy_amount). If it has no conditions, it routes immediately on creation: the executor takes the sell-token lock, calls the router, and the router either fills against the pool at a price at least as good as the limit or leaves the order resting for the next epoch. If it has conditions, it does not route on creation; it waits for the epoch boundary, where the condition evaluator either routes it (conditions hold) or cancels it (conditions fail). A partial fill at either point leaves the order resting for a retry at the next epoch. See Active routing for the full semantics.

A fill has a maker side and a taker side for a Passive order, or just a taker side for an Active order (the "maker" is the pool). A Passive fill executes at the resting maker's price; an Active fill executes at the pool's effective price for the executed amount. Both record the same Fill shape, distinguished by a source field (FillSource::Limit or FillSource::Amm). Partial fills are supported on both sides. A taker that is only partially filled rests its remainder in the book with a fresh sequence number.

Price-time priority. Orders are keyed in a pair_key || side || ~canonical_price || sequence index. The canonical frame is base = min(sell, buy), quote = max(sell, buy), and the canonical price is stored in quote per base, scaled by BOOK_PRICE_SCALE. Side 0 (sellers) store the price directly so ascending key order puts the best ask first; side 1 (buyers) store ~price so ascending key order puts the best bid first. Sequence is a monotonic u64 from nextOrderSequence() (an SMT global, so it is dry-run/apply-consistent) and breaks ties at the same price in FIFO order.

Self-trade. If the taker's owner matches the maker's, the resting order is cancelled (charging ORDER_CANCEL_FEE_ATOMIC) and the taker continues to the next resting order. Cancelled-resting is the only self-trade policy.

Cancellation, expiry, and condition failure. All three remove the order from every index — owner, pair, pool, expiry, and the book — and refund the remaining lock minus ORDER_CANCEL_FEE_ATOMIC. All three go through the same deleteRestingOrder path, so a cancel cannot accidentally leave a book row behind.

Fill history. Every fill — from the matcher or from the router — writes three rows to TBL_FILL_HISTORY: one pair-view row (keyed by pair, height, tx index, fill index) and two order-view rows (one for the maker, one for the taker; for an AMM fill, the maker slot is written with the taker's order id and the source field distinguishes the two). This is what makes getPairTrades and getOrderFills a single prefix scan instead of a receipt walk.

Receipts. A CreateOrder transaction that produces at least one fill returns a receipt whose fills vector carries the fills in execution order. This is true for both Passive (matcher) and Active-without-conditions (router on creation) orders. An Active order that routes at the epoch boundary has no transaction to attach its fills to, so those fills are committed to the header via epoch_fills_root instead (see Epoch fills commitment). Adding or removing a field from the receipt format, or from the epoch_fills_root leaf format, is a hard fork.

Book levels cap. A single taker crosses at most MAX_BOOK_LEVELS_PER_TAKER (256) resting orders in one transaction. This bounds block-apply time; the taker's remainder rests if the cap is hit.

NFTs

Clarity has a first-class NFT system. An NFT is a record in the SMT, not a contract. Every NFT is also a token with a supply of 1, so the existing DEX, AMM, and reward machinery sees it without special-casing. Wrapped NFTs hold an underlying token balance in a virtual custody address. Collections group NFTs and can carry an on-chain reward schedule paid from a pre-funded escrow. There are nine NFT transaction types.

See the NFTs section for the full model.

Claim rewards

Move an account's pending rewards into its balance. Recomputes staked amount after the transfer.

System transactions

BlockReward, OrderExpired, Slash -- used internally by the block processor to record events. Users cannot submit these.

Matching engine: how Passive orders are filled

The matching engine is a pure function of (StateAccess, Order, height, tx_index) and runs inside the block processor's write transaction. It handles OrderExecutionMode::Passive; OrderExecutionMode::Active is dispatched to the router (see Active routing) before the matcher is invoked.

Canonical book frame

Orders are created directionally: "sell A, buy B, receive at least M of B for N of A." That gives a rational limit price M/N in B per A. Two orders on opposite sides of the same pair are the same market with reciprocal limit prices.

The book canonicalizes to a single direction: quote per base, where base = min(sell_token, buy_token) and quote = max(sell_token, buy_token). The canonical price is scaled by BOOK_PRICE_SCALE = 10^10 and is stored on the book key so iteration is exact-integer and never divides in the hot loop.

Rounding is direction-preserving: a seller's limit floors (floor(min_buy_amount * SCALE / sell_amount)), a buyer's limit ceils (ceil(sell_amount * SCALE / min_buy_amount)). Both round toward the "harder to cross" side, so rounding never invents a fill that the exact rationals would have forbidden.

Book index

The book index is a separate table (TBL_INDEX_ORDER_BOOK) keyed by

pair_key(32) || side(1) || ~canonical_price(8 BE) || sequence(8 BE)

with the order id in the value. Pair key is Blake2b("obok" || LE(min(sell,buy)) || LE(max(sell,buy))), so the index is pair-symmetric. The side byte disambiguates the two sides of one pair. The ~price byte order makes ascending iteration yield best-price-first on both sides: sellers (side 0) store the price directly, buyers (side 1) store the complement, so ascending key order yields ascending ask prices and descending bid prices. The sequence is a monotonic u64 from nextOrderSequence(), an SMT global. A partial fill does not move the book row, because the row's price and sequence are unchanged.

Matching

The matcher is called from executeCreateOrder after the taker's sell-token lock has been taken and the taker's record has been written. The taker is Passive by construction (the caller dispatches). The steps are:

  1. Canonicalize the taker.
  2. Compute the taker's canonical limit price.
  3. Collect candidate (order_id, price) pairs from the opposite side's book, best-first, capped at MAX_BOOK_LEVELS_PER_TAKER.
  4. Process candidates in order. Because the book is price-sorted and the crossing check is monotone in price, the loop stops at the first candidate that does not cross.
  5. For each crossing candidate: reject stale rows, cancel self-trades, recompute the maker's canonical price from its record (which is authoritative), compute the fill quantity in base units, execute the fill, and persist.
  6. Persist the taker: delete it if fully filled, otherwise re-write it and add a book row with a fresh sequence.

The execution price is the resting (maker's) price. The fill is the minimum of the two sides' remaining quantities, converted to base units at the maker's price. Rounding is maker-favorable throughout, so neither side's lock is ever under-covered.

Self-trade

If the taker and the maker share an owner, the maker is cancelled (charging ORDER_CANCEL_FEE_ATOMIC) and the taker continues to the next resting order. The taker's own order is not removed; the taker is expected to be at a different price point.

Determinism

The matcher reads nothing from wall-clock time, hardware entropy, or peer state. Every sequence it draws comes from an SMT global, every write it makes goes through StateAccess, and every state change it produces is SMT-unconditional on both the dry-run and the apply paths. The simulate-vs-apply test in Tests/Core/BlockProcessor.cpp runs the same block through both paths and asserts the state roots match; if a future change to the matcher gates a write on !ctx.dry_run, that test fails first.

Fill history

Every fill writes three rows to TBL_FILL_HISTORY:

  • Pair view. Key = 0x01 || pair_key(32) || height(8 BE) || tx_index(4 BE) || fill_index(2 BE). Value = Fill::serializeState().
  • Order view (maker). Key = 0x02 || maker_order_id(8 BE) || height(8 BE) || tx_index(4 BE) || fill_index(2 BE). Value = the same fill bytes.
  • Order view (taker). Same shape, keyed by the taker's id.

For an AMM fill produced by the router, maker_order_id is zero and the pair view is written under the taker's id — the reader distinguishes the two by inspecting the Fill::source field.

The pair view is ordered by height ascending, so a reverse prefix scan yields trades newest-first. The order view answers getOrderFills in a single prefix scan. Nothing removes rows from TBL_FILL_HISTORY today; the table grows with fills, not with block count.

Consensus relevance

Fills are part of the consensus commitment. The Receipt::serializeState() codec emits status(1) || fee_paid(8 LE) || fill_count(4 LE) || fills(N * 55 bytes), and verifyReceiptsRoot feeds the whole receipt bytes into the leaf. A block whose receipts disagree with the applied fills produces a different receipts_root and is rejected. This is what makes a fill a first-class state event rather than a node-local annotation.

Fills produced at the epoch boundary by the Active-order router have no transaction to attach to, so they cannot flow through the per-transaction receipts_root. They are committed separately via the header's epoch_fills_root, computed over the fills' serialized bytes with an "efil" domain tag. The applyBlock path computes that root from the fills the router produced and compares it to the header; a mismatch rejects the block. The dry-run path skips the comparison, because the caller doesn't know the real value until the dry-run returns.

Limits and invariants

  • MAX_BOOK_LEVELS_PER_TAKER = 256. A single taker crosses at most 256 resting orders in one block. When the cap is hit, the taker rests its remainder. This is a consensus constant: every node must enforce the same value, and changing it is a hard fork.
  • BOOK_PRICE_SCALE = 10^10. Chosen to match PRICE_SCALE and to leave four digits of headroom above CLRTY's native granularity.
  • Order ids are assigned by executeCreateOrder from the SMT global next_order_id. Order sequences are assigned by the matcher from a different SMT global, order:next_sequence. The two counters never collide.
  • The matcher never holds a MDBX cursor across a write: it collects candidates in a std::vector, then processes them. MDBX invalidates cursors on any write through the same transaction, and the fill loop writes.

Active routing: how Active orders reach the AMM

The Active-order router is a pure function of (StateAccess, Order, height, tx_index) and runs inside the block processor's write transaction, the same as the matcher. It handles OrderExecutionMode::Active. There are two entry points:

  • On creation, from executeCreateOrder, for an Active order with no conditions. The router runs immediately after the sell-token lock is taken and the order record is written.
  • At the epoch boundary, from evaluateOrderConditions, for an Active order whose conditions hold. The evaluator walks every order in state once per epoch and dispatches Active orders with conditions to the router.

Both entry points call BlockProcessor::executeActiveOrder, which returns one of three outcomes.

When routing happens

A single Active order can be routed at most once per block: either at the transaction that created it (if it has no conditions) or at an epoch boundary (if it does). An order that partially fills leaves a remainder; the remainder is not re-routed until the next epoch boundary, even if the same block contains later transactions that would have made a second fill possible. This keeps per-block cost bounded and matches the "condition evaluated once per epoch" model.

The evaluator runs when (current_height + 1) % ROTATION_INTERVAL == 0 — the same boundary as processEpochBoundary and processRotation. It runs after transactions and expiries (so it sees the post-transaction state of the block) and before rewards (so routed fills don't participate in that block's reward distribution).

The limit math

An Active order's limit price is min_buy_amount / sell_amount, expressed in the order's own directional frame (buy_token per sell_token). The router converts this to the canonical frame (quote per base, quote = max(sell, buy), base = min(sell, buy)) using the same OrderBook::canonicalPrice function the matcher uses, so an Active order and a Passive order at the same limit compare identically against the pool.

The maximum input that satisfies the limit in a fee-bearing constant-product pool is:

y_max = reserve_out * SCALE / limit_price_scaled
      - reserve_in * 10000 / (10000 - fee_bps)

The first term is b / L (how much input produces the target output at price L if the pool had no fee). The second is a / (1 - f) (the reserve amount adjusted for the fee — the fee makes the effective input smaller, so you need a larger gross input to reach the same net). The derivation is closed-form: setting the pool's effective price equal to the limit and solving for the gross input. Both divisions are floored, which biases the result slightly below the true maximum, ensuring the effective price at the returned input is at or above the limit.

If the pool's current spot price is already at or below the limit — reserve_out * SCALE <= limit_price_scaled * reserve_in — the pool cannot fill at the limit, and the router returns Rested rather than a positive amount.

Routed, rested, unroutable

executeActiveOrder returns one of three outcomes:

  • Filled. At least one fill was produced. The order may be fully filled (record deleted) or have a remainder (record updated with a new filled_amount and re-persisted). The fills are appended to the caller's fill vector. The pool reserves, the counterparty balance, the order record, the fill-history rows, and the pool volume window are all written in the same call.
  • Rested. Nothing was written. The pool exists and contains the order's pair, but its current price does not permit a fill at the order's limit. The caller leaves the order in state; the next epoch boundary will retry it.
  • Unroutable. Nothing was written. The order can never fill against this pool: the pool does not exist, is not active, does not contain both of the order's tokens, has zero reserves on one side, or the order's canonical price overflows. The caller (the evaluator, or the executor's pre-route check) cancels the order.

The distinction between Rested and Unroutable is load-bearing: a pool that is temporarily unfavorable should not cause the order to be cancelled, but a pool that is gone should. executeActiveOrder does not cancel on its own; it reports the condition and lets the caller decide.

Fill accounting

A fill produced by the router updates five things in one call:

  1. The pool reserves. The direction is determined by which of the pool's two tokens the order sells. If sell_token == pool.token_a, the fill increments reserve_a by amount_in and decrements reserve_b by amount_out; otherwise it does the reverse.
  2. The pool volume window. The router updates the same rolling window that AmmPool::executeSwap updates for a user Swap transaction. The CLRTY-equivalent volume is computed against the pre-swap reserves, matching executeSwap's convention. A consequence is that Active-order fills count toward VolumeBelow conditions exactly as user swaps do.
  3. The counterparty balance. The order's sell-token lock was taken at order creation, so the only balance movement is the incoming token: the owner's buy_token balance increases by amount_out.
  4. The order record. filled_amount increases by amount_in (denominated in the order's own sell token, same convention as the matcher).
  5. The fill-history rows. Same three-row shape as a matcher fill, with Fill::source = FillSource::Amm, maker_order_id = 0, and pool_id set.

The Fill record uses the canonical frame: qty_base is min(sell, buy) units and qty_quote is max(sell, buy) units, regardless of which direction the order is going. price is the effective execution price (out_per_in * BOOK_PRICE_SCALE) at the executed amount.

Epoch fills commitment

Fills produced by the router on creation (an Active order with no conditions) are attached to the CreateOrder transaction's receipt, exactly like matcher fills, and are committed to via receipts_root.

Fills produced by the router at the epoch boundary have no transaction to attach to. They are committed to via a dedicated header field, epoch_fills_root, computed as a Merkle root over the serialized fill bytes with an "efil" domain tag (as opposed to the "rcpt" tag used by receipts_root, so a fill and a receipt with identical bytes cannot collide across the two trees).

BlockProcessor::applyBlock collects the fills produced by evaluateOrderConditions into a local vector, passes it to the caller via BlockResult::epoch_fills, and (on the non-dry-run path) verifies that block.header.epoch_fills_root matches the computed root. Two failure modes are rejected:

  • Non-null root, mismatched fills. The header claims a root, but the fills don't produce it. This catches a proposer that built the header from a different set of fills than the ones the block actually applies.
  • Null root, non-empty fills. The block produced epoch fills but the header does not commit to them. This catches a proposer that omitted the field.

An empty epoch_fills_root (the null hash) means "no epoch fills" and is the correct value for every non-epoch-boundary block. The dry-run path skips both checks, because the caller uses a placeholder non-null root while computing the real one.

The epoch_fills_root field is part of the block hash, so a header whose root disagrees with the fills it actually produced is rejected before any state mutation.

Cancellation policy

An Active order that becomes unroutable is cancelled by the evaluator, using the same cancelOrderByCondition path used for a condition failure. The refund is remainingAmount() - ORDER_CANCEL_FEE_ATOMIC, and the cancel removes the order from every index (owner, pool, expiry) and from the book if it was resting there.

The three cases:

  • Conditions fail. Cancel, regardless of whether the pool exists. A condition failure is the user's stated gate; it takes precedence over routing.
  • Conditions hold, pool unroutable. Cancel. The pool is gone or incompatible, and the order can never fill.
  • Conditions hold, pool routable but unfavorable. Rest. The pool exists and could fill at a different price; the order waits for the next epoch.

The same policy applies to the route-on-creation path: an Active order with no conditions that is unroutable at creation is rejected by the executor, not left in state. executeCreateOrder performs a pre-route check (pool exists, is active, contains both tokens, has non-zero reserves) before taking the sell-token lock, so a rejection cannot leave a lock or an order record behind.

NFTs: how non-fungible tokens work

Clarity's NFT system is built directly on the state layer. An NFT is a record in the SMT, not a contract, and every existing mechanism — the DEX, the AMM, the reward layer, the proof layer — treats NFTs as tokens it already understands.

The model

An NFT in Clarity is two things at once:

  1. A shadow token. Every NFT is also a TokenInfo record with maxSupply = 1 and decimals = 0. This is what the DEX, the AMM, and the reward layer see when they enumerate tokens. Its id is the NFT's id.
  2. An NftInfo record carrying the metadata (a content hash and/or URI), the collection reference, a frozen flag, and — for wrapped NFTs — a reference to an underlying token whose balance is held at the NFT's virtual custody address.

There is no VM, no script, no contract storage. The state layer knows about NftInfo directly, the same way it knows about TokenInfo, Order, and ValidatorInfo. An NFT transaction is a typed transaction with a payload, not a call into an interpreter.

Shadow tokens

Every NFT gets a shadow token created alongside it. The shadow token's id equals the NFT's id, which comes from the same SMT global counter (next_token_id) that user-created fungible tokens come from. There is no separate NFT id counter and no reserved id range; a user token and an NFT shadow cannot collide because they draw from the same monotonic counter.

The shadow token's name and symbol are inherited from the NFT's collection when it has one, or defaulted (NFT #<id> / NFT) when it doesn't. The shadow's maxSupply is 1 and its decimals is 0; the owner's balance of the shadow token is 1 while they own the NFT and 0 otherwise.

A shadow token is a token. getTokenInfo, getBalance, getTokenHolders, getTokenSupply, and getTokensForAddress all return meaningful results for it. TransferNft moves the shadow token from seller to buyer as part of the same transaction that updates the NftInfo record's owner, so a client that watches token balances sees the change without knowing what an NFT is.

Wrapped NFTs and virtual custody

A wrapped NFT holds an amount of an underlying token in custody. The custody lives at a virtual address derived from the NFT id:

Keys::nftAddress(nft_id) = Blake2b("nfa " || LE(nft_id))

This address is in the same 32-byte space as real Ed25519 public keys, so the token balance table — which keys on address bytes — treats it as an ordinary address. A wrapped NFT's underlying balance is just a normal balance at that address.

This has three consequences:

  1. The custody is real. getBalance(nftAddress(id), token_id) returns the wrapped amount. The same SMT proofs, the same historical reads, and the same token-balance indexes apply to the custody as to any other balance.
  2. Refills are ordinary transfers. TopUpNft credits the custody address from the sender. Transfer directly to the custody address also works, though it does not update the wrapped_amount field on the NftInfo record, so the record and the custody can diverge if a client uses raw Transfer. TopUpNft is the intended path.
  3. Wrapped NFTs compose with the DEX and AMM. The underlying holding is a balance like any other; the NFT's shadow token is a token like any other. An NFT that wraps an LP position can be sold as an NFT, or the underlying position can be interacted with directly by the NFT's owner using the custody address.

The wrapped_amount field on the NftInfo record is a cache of the custody balance, not a separate source of truth. It is updated by WrapNft, TopUpNft, and BurnNft; it is not decremented by TransferNft, because a transfer moves ownership of the NFT without moving the custody. The custody stays at the virtual address; only the NftInfo.owner and the shadow token's balance table entry change.

Collections

A collection is an NftCollection record. It groups NFTs and optionally carries a reward schedule. Creating a collection is one transaction; minting into it is another. A collection has:

  • A name, a symbol, and an optional base URI (inherited by NFTs that don't set their own URI).
  • A creator (the only address that can refill the reward escrow).
  • A minted_count tracking how many NFTs currently belong to it (incremented on mint, decremented on burn).
  • An optional reward schedule: a reward token id, a rate per NFT per epoch, an escrow balance, and a paused flag.

A standalone NFT with no collection is the throwaway case: one NftInfo record, one metadata blob, no collection, no reward schedule. A collection is optional.

Deleting a collection while NFTs reference it is not supported in v1. The minted_count == 0 condition — reached when all NFTs in the collection have been burned — is the natural gate for a future DeleteNftCollection transaction.

Reward schedules

A collection can carry a reward schedule: a rate per NFT per epoch, drawn from a pre-funded escrow. The schedule is per-collection, not per-NFT, because a 10,000-NFT collection with the same rate for every NFT is 100× cheaper to track as one schedule than 10,000.

The reward accrual model is accrue-then-claim:

  • At the epoch boundary, processEpochBoundary walks every collection with an active schedule and decrements reward_escrow_remaining by minted_count * reward_rate_per_epoch. If the escrow cannot cover the obligation, it drains what's left and sets reward_paused = true. The epoch boundary does not credit any individual holder; it only moves the escrow.
  • At claim time, ClaimNftReward walks every NFT the caller owns, computes the accrual since the NFT's last_accrual_height ((current_height - last_accrual_height) / ROTATION_INTERVAL * reward_rate_per_epoch), credits the caller's pending_rewards, and resets the NFT's last_accrual_height. The credits flow through the existing ClaimRewards path — NFT reward claims land in the same pending_rewards bucket as staking rewards, so a caller who has both claims them with one transaction.

The accounting invariant is that the collection's escrow is the single source of truth. The sum of all pending claims across all NFTs is always ≤ the total the escrow has decremented for. Two mechanisms enforce this:

  • The epoch-boundary decrement is the only place the escrow decreases. A RefillCollectionReward transaction is the only place it increases.
  • A claim never decrements the escrow; it only settles the accrued amount into the holder's pending_rewards. Since the escrow decremented at the epoch boundary by the full per-epoch obligation, and every NFT's last_accrual_height advances only when the holder acts, the pending claims cannot exceed what the escrow has already accounted for.

The epoch-boundary pass is O(collections), not O(NFTs). A chain with 1,000,000 NFTs across 100 collections pays 100 SMT writes at each boundary, not a million.

Transfers and burns settle accrued rewards to the current owner before the NFT record is updated or deleted, so a seller cannot lose rewards they earned and a buyer cannot claim rewards the seller earned.

Transaction types

Nine NFT transaction types:

  • CreateNftCollection — creates an NftCollection record. If a reward schedule is specified, the escrow is funded from the creator's balance of the reward token in the same transaction.
  • MintNft — mints a standalone NFT. Creates the shadow token, mints it to the creator, and writes the NftInfo record. collection_id is optional; if set, the collection's minted_count is incremented.
  • WrapNft — mints a wrapped NFT. Takes wrapped_amount of an underlying token from the creator, credits it to the NFT's virtual custody address, and writes the NftInfo record with underlying_token_id and wrapped_amount set. A wrapped NFT's underlying cannot be the NFT's own shadow token.
  • TransferNft — transfers an NFT to a new owner. Moves the shadow token balance, updates the NftInfo.owner, and settles accrued rewards to the seller. The custody — if any — stays at the NFT's virtual address.
  • BurnNft — burns an NFT. Deletes the shadow token balance and the NftInfo record. If the NFT was wrapped, the custody is released to the current owner. If the NFT was in a collection, the collection's minted_count is decremented.
  • UpdateNftMetadata — changes an NFT's metadata hash and/or URI. Owner-only. A frozen flag can be set on the same transaction, after which no further metadata changes are possible.
  • TopUpNft — credits additional underlying to a wrapped NFT's custody from the caller. The token must match the NFT's underlying (for wrapped NFTs) or be any token (for standalone NFTs; the custody is a real balance but the wrapped_amount field stays zero).
  • ClaimNftReward — settles all of the caller's accrued NFT rewards into pending_rewards. Empty payload. The caller then claims normally.
  • RefillCollectionReward — creator-only. Adds to a collection's reward escrow and un-pauses it if it was drained.

Ownership and transfers

An NFT's owner is recorded in two places: the NftInfo.owner field and the shadow token balance table (the owner has a balance of 1). Both are updated by TransferNft in the same transaction. The shadow token balance is the more general source of truth — a client that watches token balances sees the ownership change without knowing about NFTs — but the NftInfo.owner field is what the executor checks for authorization.

TransferNft uses unindexNft on the old record before putNft on the new record, so the previous owner's forEachNftForOwner no longer returns the NFT after the transfer. This is why putNft itself does not read the old record: a read inside a write path interleaves with the tree's content-addressed node update buffer and can cause subsequent updates in the same transaction to be silently dropped. The cleanup is the caller's job, and the transfer executor does it explicitly.

Validator identity: two keys, two roles

Every validator has two keys with independent lifecycles:

Consensus key

Signs proposals, prevotes, precommits, and timeout attestations. It is the validator's identity for BFT purposes and for the equivocation-proof and timeout-certificate machinery. Held as a raw hex secret read by the daemon at startup (--consensus-key <hex>). Mutable via the RotateConsensusKey transaction -- see below.

Reward address

The address where the validator's block rewards are paid. It is a user-facing key, normally backed by a mnemonic keystore. Freely changeable via the UpdateRewardAddress transaction, with no effect on consensus participation. The daemon never touches it.

Both keys are 32-byte Ed25519 public keys. They can be the same value -- a validator registered with a single key uses it for both roles, which is the default for the seed validators. They don't have to be, and the whole point of the split is that they don't.

The daemon resolves the signing key through ValidatorInfo::effectiveConsensusKey(), which returns consensus_key when set and falls back to reward_address otherwise. The fallback keeps every pre-split validator record verifiable: a validator whose consensus_key is null signs and verifies with its reward address, exactly as before the split. New registrations set consensus_key = reward_address explicitly, and the first UpdateRewardAddress on a legacy validator pins the current effective key so the reward address can move without rotating the signing key.

Rotating the Consensus Key

A validator can change its consensus signing key at any time via a RotateConsensusKey transaction. The old key stops being valid for new signatures the moment the rotation commits; it remains valid for verifying signatures that were cast at earlier heights.

The history. Every validator carries a consensus-key history, stored in the SMT under a companion key (Keys::validatorKeyHistory) rather than on the ValidatorInfo record itself. The history is a list of (key, valid_from_height, valid_until_height) entries ordered by valid_from_height. The interval is half-open; valid_until_height == 0 on the last entry means "current." The ranges are gapless and non-overlapping by construction, so every height in a validator's life has exactly one authoritative key.

Authorization. Only the validator's owner can submit a rotation. In current deployments owner == reward_address, so the reward address is the effective authority. Rotation is rejected if the new key equals the current effective key (a no-op is a user error), if the caller is not the owner, if the validator has a pending unbond in flight (a departing validator should not extend its signing window), or if the payload is not exactly 32 bytes.

Verification by height. verifyEquivocationProof, checkQuorum, and checkTimeoutCertificate all resolve the signing key against the history at the height the vote was cast — not against the validator's current key. This is what makes a rotation compatible with the slashing machinery: a validator that equivocates at height H is slashed by proving two conflicting votes at H, and those votes are verified against the key that was authoritative at H, which may no longer be the validator's current key. The proof's signer_id is stable across rotation; only the key resolution changes.

This is the tight version of the equivalence class: "signature verifies against the key that was authoritative at vote.height." A looser rule — "signature verifies against any key the validator has ever held" — would mean a validator whose old key is compromised can be slashed for votes that key never signed. The tight rule is what preserves the property "you can only be slashed for votes you were authorized to cast."

Migration. A validator registered before the rotation feature existed has no history. validatorKeyAtHeight handles this by falling back to the record's effectiveConsensusKey() — the same answer the pre-feature code gave. On the first rotation, the history is initialized with a single entry covering from the validator's registered_at_height to the current height, and the new key becomes the current entry.

Lifecycle. The history is initialized at genesis for the seed validators and at registration for every other validator. It is deleted with the record when a validator's unbond period expires (processUnbondExpiries).

The history is a consensus-relevant structure. Adding a rotation changes the state root; the block-processor tests that assert simulate/apply root equality cover this path.

Tooling

A single address binary handles all key management for validators and users:

  • address --new -o <path> creates a fresh keystore from a new BIP-39 mnemonic. It derives and prints both keys for all three networks in one run.
  • address --import -o <path> creates a keystore from an existing mnemonic.
  • address --show <path> prints every key in an existing keystore.
  • address --show --show-secret additionally prints the raw hex secrets. This is the command to run when you need the consensus secret for the daemon's --consensus-key flag.
  • address --from-mnemonic "<words>" derives and prints keys without writing a keystore.

Network: how nodes talk

TCP transport over IPv4 and IPv6. Each peer connection goes through a handshake: exchange version messages, verify protocol compatibility, exchange a signed challenge-response, complete a session-key confirmation, exchange addresses. Once established, peers route messages by type -- proposals, votes, transactions, block announcements, sync requests, proof requests, and peer address requests -- over an encrypted channel.

Message framing

10-byte header (4-byte magic, 2-byte type, 4-byte length) followed by an opaque payload. Magic is chain-ID-based, so peers on different networks don't connect. Max message size is 16 MiB. The header is always plaintext; the payload is encrypted once the session is established (see Session encryption).

Handshake

After TCP connect, the two peers exchange Version messages (protocol version, network nonce, agent string, best chain height, listen port). They then exchange Verack acknowledgements. Then they exchange Auth messages -- a signed challenge-response carrying both the peer's identity public key and an X25519 ephemeral public key. Finally they exchange AuthReady messages, which confirm that both sides derived the same session key. Only after AuthReady is a peer considered Established.

Authentication

Every peer must complete the Auth exchange before reaching Established. The challenge is Blake2b(initiator_nonce || responder_nonce || identity_pubkey || ephemeral_pubkey), so a signature captured from one session can't be replayed in another, and a MITM cannot substitute the ephemeral key without invalidating the signature. The identity pubkey is bound to the peer's validator ID by looking up the validator whose consensus key matches -- a non-validator peer is still authenticated but claims validator_id = 0. Failed auth is an immediate ban.

The Auth message also carries an X25519 ephemeral public key, which the signature covers. This key is the input to the session-key agreement (see Session encryption). It is generated fresh per peer connection and never persisted.

On a validator node, the daemon uses the validator's consensus key as its node identity. On a non-validator, it loads or generates a per-node key at <data_dir>/node_key.

Session encryption

After Auth completes, the two peers exchange an AuthReady message. This is the synchronization point for the encrypted session: each side sends AuthReady only after it has both sent and verified the peer's Auth, so neither side ever encrypts before the other can decrypt.

The session key is derived from X25519(our_ephemeral_secret, their_ephemeral_public) combined with both ephemeral public keys, both identity public keys, and both network nonces, all hashed through Blake2b with a domain separator. The result is split into two directional keys via labelled Blake2b calls -- the lower-nonce side sends under one key and receives under the other, and the roles flip on the higher-nonce side. The AAD on every AEAD frame binds the message type and the direction, so a frame cannot be replayed in the reverse direction or reframed as a different type.

AuthReady itself is the first encrypted message; its AEAD tag is the key-confirmation. If the two sides derive different keys (a bug, a version mismatch, or an active attacker), the tag fails to verify, and the peer is closed without a ban -- a session-key disagreement is not evidence of misbehavior.

Every message after AuthReady is encrypted with XChaCha20-Poly1305. Each direction has its own 8-byte counter, zero-padded to the 24-byte extended nonce, and no nonce is ever reused. The identity keys are not used for key agreement -- an ephemeral X25519 keypair per session gives forward secrecy, which a static-static exchange on the identity key would not.

Sync

A peer that falls behind catches up via GetHeaders / Headers / GetBlocks / Blocks. The SyncManager per peer drives a small state machine -- request headers, receive them, request blocks, apply them, repeat -- bounded by MAX_HEADERS_PER_REQUEST = 2000 and a computed MAX_BLOCKS_PER_REQUEST that fits in one message. Blocks are applied through Node::applyCommittedBlock, the same path consensus uses, so sync and consensus stay consistent. Requests time out (30 s for headers, 60 s for blocks); timeouts score the peer but don't disconnect.

Idle refresh

A SyncManager that's caught up periodically re-issues GetHeaders (default every 30 s) so that a peer that commits new blocks while we're Idle is discovered without needing a relay message. This is what makes a node self-heal after briefly falling behind.

Block relay

After a validator commits a block, it broadcasts the full serialized block to every Established peer. Recipients apply it if it's the next block, then re-broadcast. A node that already has the block drops it silently -- this is what breaks the propagation loop. A block that doesn't connect to the current head is dropped; sync fills the gap.

Transaction relay

A node that accepts a transaction into its mempool broadcasts it. Recipients add it and re-broadcast. A node that already has the tx (mempool_->contains(txid)) drops it silently -- same loop-breaking discipline as block relay. This is what lets a non-validator node submit a transaction to the network.

Proof serving

A client that holds a state root can request a proof for any key at any version via GetProof / Proof. The request carries a typed descriptor -- an account address, a (address, token id) pair, a validator id, a global state name, or an AMM pool id -- rather than a raw SMT key, so the client never has to know how keys are derived. The server resolves the descriptor to the same 32-byte SMT key the state layer uses, walks the tree from the requested version's root, and replies with the proof and the root it was proven against.

The root travels with the proof because the client's verification must be self-contained: verifyProof(expected_root, proof) recomputes the root from the proof and compares. The client does not need to trust that the server picked the right root, because if it picked a wrong one, verification fails. This is what makes a light client possible without syncing the chain.

Proofs can be requested against the current head (a sentinel version) or against any historical version that hasn't been pruned. Non-inclusion proofs (the account is empty at that version) and inclusion proofs use the same message; the client distinguishes them by inspecting the proof's terminal descriptor and value field. A reply carries a status field: Ok when a proof was produced, VersionUnavailable when the requested version has no saved root, KeyNotFound for a server that cannot serve the key (a pruned or inconsistent DB), and Malformed when the request's descriptor didn't match its declared type.

Serving a proof is expensive -- a tree walk plus a several-KB response, with the exact size scaling as O(log N) rather than the fixed 9 KB the uncompressed tree produced -- so GetProof carries a higher rate-limit cost than a header request, and Proof carries a non-trivial cost because the receiving peer still has to deserialize and re-verify the proof.

Rate limiting

Each peer has a token bucket for its message stream, plus a stricter separate bucket for consensus messages. The cost per message reflects the asymmetry of work -- GetHeaders and GetBlocks cost 20 tokens, GetProof costs 50, Tx costs 5, Proof costs 5, control messages cost 1, consensus messages use the separate bucket. A peer that floods expensive-to-serve messages is cut off and disconnected.

Above the per-peer buckets is a node-wide aggregate bucket. The per-peer bucket bounds what any one peer can do; it does not bound the sum: many peers each staying under their own cap can still saturate the io_context thread's serving capacity. The aggregate bucket is charged by message type at the same point the per-peer bucket is, using a separate and deliberately compressed cost table -- GetProof costs 10 at the aggregate level, not 50, because a single legitimate proof request should not consume an eighth of the budget. Consensus types are charged at the aggregate level even though they bypass the per-peer general bucket; a crowd of non-validators sending junk proposals is a real flood vector. A trip on the aggregate bucket closes the offending connection without reporting misbehavior or banning -- a peer behind a shared NAT can trip the aggregate budget through no fault of its own. Both buckets use Common::RateLimiter; both are disabled by setting their burst and refill to zero.

The RPC layer has its own per-source-IP token bucket with the same Common::RateLimiter.

Peer management

An address book stores known peers, persisted to disk. A ban list records misbehaving peers, with a threshold at which they're banned. Outbound connection maintenance dials enough peers to maintain a target. The manager also handles inbound connections up to a maximum.

Self-connection prevention

Each node generates a random 64-bit network nonce at startup. The nonce is exchanged during the Version handshake. If a node receives a Version message with its own nonce, it closes the connection -- it has dialed itself. The check is done after the Version exchange and before Verack, so both sides observe the nonce before either transitions to the auth phase.

Metrics: how operators watch the node

Clarity exposes a Prometheus-compatible metrics endpoint on a separate port from the RPC endpoint. It is enabled by default, bound to 127.0.0.1:9100, and speaks the Prometheus text exposition format (text/plain; version=0.0.4).

The endpoint lives on its own HTTP server, independent of the RPC server: a separate port, a separate worker pool, a separate rate limiter, and a separate handler. This is deliberate. RPC endpoints are frequently exposed to the public internet, and metrics endpoints frequently are not — the operator should be able to make that choice for each independently. The two also have different audiences: RPC is for application clients, metrics is for scrapers, and combining them would force both to share a policy.

Defaults.

--metrics               enable the endpoint (default: on)
--no-metrics            disable the endpoint entirely
--metrics-bind <addr>   bind address (default: 127.0.0.1)
--metrics-port <port>   listen port (default: 9100)
--metrics-include-validators
                        include per-validator metrics (default: off)

The per-validator block adds six time series per active validator. On a 100-validator chain that's 600 series per node, fine for a monitoring stack but wasteful for an operator who just wants to watch their own node. Off by default.

Scraping.

curl -s http://127.0.0.1:9100/metrics

On a two-validator regtest network with the defaults, the endpoint returns roughly 50 fixed series plus the per-validator block if it's enabled. The metric families are:

Group What it reports
clrty_height, clrty_running, clrty_chain_id, clrty_uptime_seconds Node identity and liveness
clrty_p2p_* Peer counts by direction and state, ban list size, address book size, aggregate rate-limiter config
clrty_mempool_* Pending transaction count, byte size, fee-tier split, fee-rate min/max/avg, and cumulative accept/reject counters with a reason label
clrty_storage_* On-disk database size, block count, SMT node and leaf counts
clrty_fees_in_window Rolling 24-hour fee total, maintained incrementally at each block commit and read from the meta table rather than by walking headers
clrty_pot, clrty_total_supply, clrty_total_staked, clrty_staker_count, clrty_last_effective_apy_bps, clrty_current_epoch, clrty_blocks_until_epoch Reward pool and staking state
clrty_consensus_*, clrty_active_set_size Consensus height, round, step ordinal, proposer flag, vote counts, consecutive timeouts, emergency rotation state, active set size and quorum threshold
clrty_validator_* (opt-in) Per-validator stake, uptime EMA, reward multiplier, cumulative rewards and blocks produced, pending unbond height

Failure modes. A path other than /metrics returns 404. A method other than GET returns 405. If a subsystem accessor throws during rendering, the whole scrape returns 500 rather than emitting a partial body — a partial scrape would leave some metrics at their last values while others errored, and Prometheus would silently carry the stale values forward. A clean failure is the honest signal.

Concurrency. The scrape runs on the metrics server's worker thread, never on the P2P event loop. Every value comes from one of four sources: Node::Status (a value struct returned by Node::status()), the memory-mapped database through StateAccess, lock-free atomic counters on P2PManager (read via snapshot()), or value-returning accessors on Mempool and BftConsensus that take their own short-lived mutexes. None of them requires posting a request to the event loop and waiting for a reply. A scrape never blocks on the event loop, and the event loop never blocks on a scrape.

Port collisions. Running two daemons on one host requires each to have a distinct metrics port. The second daemon binds 9101, the third 9102, and so on — the same as with any other network service. The Setup Clarity REGTEST guide shows this pattern for the two-validator devnet.

RPC: how clients talk

JSON-RPC 2.0 over HTTP. The dispatcher accepts single requests and batches, handles notifications, and routes by method name. Every method is registered with a handler that receives (Node&, RpcConfig&, JsonRpcRequest&) and returns a Json result or throws an RpcMethodError.

Encoders produce the wire format. Numbers on the wire are "0x"-prefixed hex strings to avoid JS precision loss above 2^53. Addresses are Bech32m (clrty1..., tclrty1..., rclrty1...) for the same reason every user-facing surface in the codebase uses Bech32m. Hashes and signatures are "0x"-prefixed hex. Raw hex addresses are accepted on input for tooling.

Validator objects returned by the consensus methods expose both reward_address (bech32m) and consensus_key (0x-prefixed hex), in addition to the existing fields.

Method surface. The dispatcher registers roughly eighty methods across eleven groups:

  • Chain: chainId, blockNumber, getBlockByNumber, getBlockByHash, getBlockHeaderByNumber, getRecentBlocks, getStateRoot, getBlocksRange, getBlocksByProducer, getBlockStats, getChainStats, getEpochInfo, getEpochSchedule.
  • State / accounts: getBalance, getAccount, getNonce, getTokensForAddress, getPositionsForAddress, getOrdersForAddress, getTransactionsForAddress, getStakingInfo, getAddressStats, getAddressList, getProof.
  • Tokens: getTokenInfo, getTokenSupply, getAllTokens, getTokenHolders, getTokenTransfers, getTokensByCreator.
  • Transactions: sendRawTransaction, getTransactionByHash, getTransactionReceipt, simulateTransaction, getRecentTransactions, getAllTransactions, getTransactionsByType, getTxCount.
  • Mempool: getMempoolStats, getMempoolTx, getMempoolList.
  • Consensus: getValidators, getValidator, getValidatorByAddress, getValidatorStats, getActiveSet, getConsensusState.
  • AMM: getPool, getPosition, getPositionByOwner, getAllPools, getPoolsForToken, getPositionsForPool.
  • Orders: getOrder, getOrdersExpiringAt, getAllOrders, getOrdersByPair, getOrderBook, getOrderFills, getPairTrades.
  • NFTs: getNft, getNftsForOwner, getNftsForCollection, getCollection, getAllCollections.
  • Metrics (server-side aggregates): getFeeStats, getPotInfo.
  • Node / utility: ping, status, health, getPeers, getConfig, getMethods, web3_clientVersion, net_version, net_peerCount, net_listening.

Order book methods. getOrderBook(base, quote, depth) returns aggregated bids and asks, best-first, up to depth levels. Prices are in the canonical frame (quote per base, scaled by BOOK_PRICE_SCALE), which is exposed alongside the levels so a client can convert to a human price. getOrderFills(order_id) returns the newest fills for one order (either side) from the order-view of TBL_FILL_HISTORY. getPairTrades(base, quote, limit) returns the newest fills on a pair from the pair-view. All three are read-only and can be served from an autocommit StateAccess.

NFT methods. getNft(nft_id) returns one NFT's full record, including the shadow token id, the underlying token and wrapped amount (for wrapped NFTs), the collection id, the metadata, the freeze flag, and the derived custody address. getNftsForOwner(address, limit, offset) enumerates the NFTs held by an address via the owner index. getNftsForCollection(collection_id, limit, offset) enumerates the NFTs in a collection via the collection index, and rejects early with NftCollectionNotFound if the collection does not exist. getCollection(collection_id) returns the collection record with its reward schedule and current escrow. getAllCollections(limit, offset) pages every collection.

Fill encoder. A Fill on the wire carries its source field ("limit" or "amm"), the maker_order_id (zero for an AMM fill), the taker_order_id, the pool_id (zero for a limit fill), the qty_base and qty_quote in the canonical frame, the price (scaled by BOOK_PRICE_SCALE), and the base_token and quote_token ids. A client that wants to know whether a fill was a book match or an AMM route reads source, not the presence or absence of a maker id.

Error responses use JSON-RPC standard codes for InvalidRequest, MethodNotFound, InvalidParams, InternalError, plus a project-specific range (BlockNotFound, TransactionNotFound, ReceiptNotFound, PoolNotFound, OrderNotFound, ValidatorNotFound, TokenNotFound, NftNotFound, NftCollectionNotFound, TxMalformed, ChainReadInternal, StateReadInternal, Unauthorized, ProofVersionUnavailable, ProofNotAvailable, etc.). The InternalError catch path in the dispatcher swallows exception text -- a deliberate v1 choice documented in the code.

Admin methods require a bearer token in the Authorization header. The token is threaded through a thread-local set by HttpConnection before dispatch, since JsonRpcRequest doesn't carry transport-level data. An empty admin_token in the config means admin methods aren't registered at all.

Rate limiting: per-source-IP token bucket, applied at accept time before the request is dispatched to a worker. Configurable via rate_limit_burst and rate_limit_per_second; both zero disables. Oversize or malformed requests are rejected with 400; rate-limited requests get a 429.

Wallet: how keys are managed

Keystores are encrypted JSON files. Each file contains an Argon2id or PBKDF2-derived key, a ChaCha20-Poly1305 ciphertext of the seed material, a MAC, a nonce, a salt, an optional user label, and the seed fingerprint. The format is versioned and validated on load; tampered ciphertext, MAC, nonce, salt, or AAD is rejected at unlock.

HD derivation follows BIP39 for mnemonic-to-seed and SLIP-10 for the derivation path. Two chains are in use:

  • m/44'/9000'/account'/0'/index' -- reward addresses (the "receive" branch).
  • m/44'/9000'/account'/2'/index' -- validator consensus keys.

All components are hardened, since SLIP-0010 for Ed25519 requires hardened-only derivation.

Signing

A LocalSigner wraps an unlocked keystore and registers derived keys by public key. sign(hash), signAtPath(path, hash), and signBatch produce Ed25519 signatures. All signing fails while the keystore is locked, and a derived key that hasn't been registered can't be signed with (the signer refuses rather than deriving on the fly).

Address codec

Bech32m with distinct HRPs per network (clrty, tclrty, rclrty). Decoding is strict: mixed case rejected, wrong network rejected, tampered checksum rejected. Addresses are 32-byte Ed25519 public keys with a one-byte witness version prefix.

Fingerprint

A stable, per-mnemonic identifier computed from the seed, used to detect when a keystore file's declared fingerprint doesn't match the one derived on unlock. This catches accidental keystore swaps or restores.

CLI wallet client

An interactive clarity-wallet binary opens a keystore, connects to a node's RPC endpoint, and provides a REPL for common operations:

balance [address] [--verify]    show the balance of an address
nonce [address]                 show the current nonce
info                            show the open keystore's metadata
status [txid]                   show a transaction receipt
send <to> <amount> <fee>        build, sign, submit a transfer
claim <fee>                     claim pending rewards
stake <opt-in|opt-out> <fee>    toggle staking
validator info                  show the validator record for this account
validator register ...
validator update-reward ...
validator rotate-key ...
validator unregister ...
connect <host:port>             set the RPC endpoint
connect-peer <host:port>        set a second RPC endpoint for proof verification
open / close                    session management

The wallet derives keys locally, signs with the reward key, and submits via clrty_sendRawTransaction. It does not manage consensus keys -- those are raw hex secrets the daemon reads directly.

The library layer also carries nine NFT transaction builders — buildCreateNftCollection, buildMintNft, buildWrapNft, buildTransferNft, buildBurnNft, buildUpdateNftMetadata, buildTopUpNft, buildClaimNftReward, buildRefillCollectionReward — mirroring the existing build* functions. Each takes a parameter struct, validates locally, and returns an unsigned Core::Transaction that the caller signs and submits. The CLI does not yet expose NFT commands, but the builders are the integration point for a wallet that wants to mint, wrap, or transfer NFTs.

balance --verify fetches the state root from the connected node, optionally requires a second endpoint (connect-peer) to agree on that root, and then requests an SMT proof for the account. The proof is verified locally with State::verifyProof before the balance is reported, so the value comes from a proof rather than the server's word. Without a peer endpoint configured, the wallet warns that it is trusting a single server for the root. With one, two independent servers must agree before either is trusted.

Startup wizard

If no --keystore is passed, the wallet runs an interactive setup: probe the RPC endpoint (with retry/offline options), discover keystores in the working directory, confirm the selected one, and unlock it. If --keystore is passed, the wizard skips discovery and goes straight to unlock.

Address book pattern

The wallet layer supports the operations you'd need for an address book -- multiple keystores, independent derivation paths, labels -- but the CLI does not yet expose those operations. Integrators drive the library directly.

What's notably absent

No divergence detection or repair. With the atomic commit discipline (state writes, block index writes, and head update in one MDBX transaction), divergence shouldn't be possible. But if it happened -- from a bug, or from a partially-written restart -- the node has no detection or recovery path.

No RPC authentication beyond the admin token. Read methods are unauthenticated. If the RPC endpoint is exposed to the public internet, anyone can query balances, blocks, and state roots. That's usually fine for a public node; it becomes a problem if the RPC endpoint is also used as a control plane. The metrics endpoint is bound to loopback by default for the same reason, but has no authentication at all -- the design assumes that a scrape endpoint on a private interface is not a secret.

No wallet CLI for address-book operations. The clarity-wallet client covers balance, transfer, staking, and validator operations, but not multi-account management or address labels. Integrators still drive those through the library.

No wallet CLI for NFT operations. The library layer has the nine builders, but the interactive client does not yet expose mint, wrap, transfer-nft, or collection commands. A user who wants to mint an NFT today calls the builders directly from an integration. This is the same shape as the address-book gap: the pieces are there, the CLI surface is not.

No collection deletion. A collection with NFTs in it cannot be deleted. Deleting an empty collection (minted_count == 0) is not exposed as a transaction in v1. The minted_count == 0 condition is the natural gate for a future DeleteNftCollection, but there is no need for it today, and forbidding deletion while NFTs reference a collection is the safer default.

No batch mint. Minting N NFTs is N MintNft transactions. A MintNftBatch transaction that creates up to 256 NFTs in one call would be a straightforward extension — the executor logic is the same loop — but it is not in v1. The RPC getNftsForCollection returns paginated results, so a client that mints in bulk has no read-side problem; the write side is a matter of when the gas budget makes batching worthwhile.

No per-NFT reward schedules. A reward schedule lives on a collection, not on an individual NFT. Every NFT in a collection accrues at the collection's rate. Per-NFT schedules would be more flexible and roughly 100× more expensive at the epoch boundary. Per-collection is the correct default for v1; a per-NFT override is an additive change if there is ever demand.

NFT rewards don't pay out automatically. The collection's escrow decrements at every epoch boundary, but the holder's pending_rewards bucket is credited only when they call ClaimNftReward (or when they call ClaimRewards, which settles the NFT accrual through the same path). A holder who never claims keeps accruing in the collection's accounting but sees no balance change. This is intentional: a push-based payout would cost one SMT write per NFT per epoch, which is unbounded; accrue-then-claim costs one write per holder per claim, which is bounded by activity. The trade-off is that a holder has to submit a transaction to see the reward — but they already have to submit one to move the reward out of pending_rewards into balance, so the cost is the same.

AMM positions are not deleted when a pool closes. When the last LP drains a pool, the pool record is deleted but the zeroed AmmPosition record persists. It is unreachable from any index (the position index was removed in the same operation), so it is invisible to any caller that doesn't already know its id -- but it costs 64 bytes per position ever created, and a forEachPosition-style scan would surface them. Cleaning them up would require either a pool_id -> [position_ids] index or a scan bounded by how many LPs the pool ever had. Neither is worth the code today; the historical SMT already preserves the fact that the position existed.

The wallet uses RPC-proxied proofs, not the P2P protocol. balance --verify requests a proof over the clrty_getProof RPC method and verifies it locally against a state root the wallet obtained from (optionally two) RPC endpoints. The proof itself is verified with the same State::verifyProof function a light client would use, so a server that lies about a balance produces a failing proof. But the root comes from the RPC endpoint, not from the chain -- a client that doesn't want to trust any RPC endpoint at all would need to fetch headers over P2P, follow the header chain, and obtain the current root independently. That's the GetHeaders/Headers P2P pair plus header-chain verification in the wallet, and it's the next step toward a full light client. The RPC path closes the "does anything use the proof protocol" gap; the P2P path would close the "is the root itself trusted" gap.

No cross-network genesis replay protection beyond the chain ID. A transaction is signed over a chain ID, so a mainnet transaction cannot be replayed on testnet and vice versa. But two networks that share a chain ID would have compatible signatures; the design assumes the three networks use distinct chain IDs, and no runtime check enforces this beyond the genesis constant.

No slashing for liveness failures. A validator that is offline loses active-set membership through the offline-removal path, but its stake is not reduced. The only slashing offense is equivocation -- a provable fact. Downtime, missed votes, and repeated timeouts are penalized only through loss of rotation priority and reward multiplier decay. This is a design choice: liveness failures are often environmental, and slashing for them would punish a validator for a datacenter outage rather than malice.

Live SMT tables accumulate stale nodes. TBL_SMT_NODES and TBL_SMT_LEAVES are content-addressed and never delete. Every distinct subtree the chain has ever produced has a row, whether or not the current root reaches it. This grows with state churn, not with the size of the current tree, and is small next to the historical tables for a chain that grows monotonically. A periodic mark-and-sweep that deletes rows unreachable from the current root would bound it, but it is not implemented in v1.

No per-table byte metrics. The metrics endpoint reports total DB size and a few table entry counts (smt_nodes, smt_leaves), but not per-table byte estimates. The StateDB::tableStats API exists and can be wired into the metrics handler in a few lines; the current endpoint simply doesn't call it. Until it does, diagnosing "which table is eating disk space" requires mdbx_stat.

Design choices worth noting

Base58 is CryptoNote-style, not Bitcoin-style. The test file explicitly notes this. Addresses produced by Clarity are not interoperable with external Base58 tools. This is a legacy surface -- the RPC and wallet layers emit Bech32m -- but any code that still uses the Base58 encoder would need to migrate before clients expect external interoperability.

One block per round, immediate finality. No fork choice, no probabilistic finality, no reorgs. Simpler than Bitcoin-style consensus and matches the BFT model, but the network can't make progress if fewer than bftQuorum validators are online.

Reward distribution is epoch-based for stakers, block-based for validators. Validators get paid every block. Stakers get paid every epoch (60 blocks). Keeps the per-block state transition cheap and defers the expensive iteration to the epoch boundary. NFT collection reward accrual joins the epoch-boundary pass: the per-epoch obligation is minted_count * reward_rate_per_epoch, and the pass is O(collections), not O(NFTs).

Pot mechanism as a smoothing buffer. The pot accumulates staker rewards when activity is low and releases them when activity is high. Decouples the staker payout from the current block's activity, giving a more stable APY. The pot is the destination for every unallocated reward: validator pool remainders when the active set contains unknown validator IDs, reward multiplier penalty differences, seed-only set shares, and slash proceeds.

Validator rotation is bounded by BFT safety. computeRotationCount never removes more validators than would keep the remaining set above quorum. A validator set of 4 can rotate at most 1 per epoch; the cap is n - bftQuorum(n), which for n=100 is 33, but the epoch-proportional bound ceil(n/21) gives 5 for n=100. Both bounds are applied and the smaller one wins.

State transitions are pure functions of the block and the state they run against. Nothing inside applyBlock reads wall-clock time, local peer state, or hardware entropy. This is why the emergency rotation decision lives in the block header rather than a per-node timer: a node can look at its own clock to decide whether to stamp the flag, but only the value it stamped goes into the block, and only the block feeds the state. The matching engine, the Active-order router, the key-history maintenance paths, and the NFT reward accrual are held to the same rule: they read nothing but their arguments.

Wire codec is a codec, not a validator. BlockHeader::deserialize round-trips bytes without applying consensus rules. isWellFormed is a separate, explicit step the acceptance path runs. This matters for three reasons: historical blocks remain readable if rules change, diagnostic tools can inspect malformed blocks without satisfying consensus rules, and test fixtures can construct blocks whose fields aren't all populated yet.

Dry-run block application. BlockContext::dry_run skips structural header checks, quorum verification, and the state-root comparison. The proposer relies on this to simulate a candidate block before populating the state root and collecting signatures. The non-dry path runs every check. The invariant that makes this safe is that every state change that touches the SMT happens on both paths; only writes to the meta table and to non-SMT index tables may be gated on !ctx.dry_run. The matching engine, the Active-order router, the key-history writes, and the NFT reward accrual all obey this: they write their book rows, their fill-history rows, their sequence counter, their pool updates, their key-history entries, and their collection escrow decrements through the SMT unconditionally, and the simulate-vs-apply tests in Tests/Core/BlockProcessor.cpp and Tests/Core/Nft.cpp are the canaries.

The book is canonicalized, not directional. The order wire format is directional — "sell A, buy B" — because that is what the user means. The book index is pair-symmetric: base is min(sell, buy), quote is max(sell, buy), and the side byte captures the direction. A sell-A-buy-B order and a sell-B-buy-A order end up in the same index range, on different sides, at reciprocal prices. This is what makes the matcher's crossing check a single comparison with no division in the hot loop.

The book index is a placement index, not a state mirror. putOrder writes the order record and the owner, pair, and pool indexes. It does not touch the book index. The book index is written by addToOrderBook when an order first rests and removed by removeFromOrderBook when the order leaves the book. A partial fill does not move the book row, because the order's price and sequence are unchanged. This split keeps the write path cheap and the book index's semantics clear: it says "this order is currently resting at this price and this sequence", nothing more.

Fill history is a node-local index, but the fills themselves are consensus state. The TBL_FILL_HISTORY rows are non-SMT, so a node that never wrote them would still agree with one that did. The fills carried on the receipt (and on the epoch-fills root) are SMT-relevant because the receipt's bytes feed into receipts_root and the epoch fills feed into epoch_fills_root. The distinction matters: a node can prune TBL_FILL_HISTORY or rebuild it without a fork, but it cannot change the fill fields on a receipt or in the epoch-fills root without one.

Order ids and order sequences are separate counters. next_order_id is allocated by the executor and identifies the order globally. order:next_sequence is allocated by the matcher and orders book rows at the same price. They are distinct SMT globals and they never collide. The first is part of every receipt and every index row. The second is internal to the book index and never appears in a receipt.

Active fills commit through one of two channels, depending on when they happen. A fill produced at order creation rides the transaction's receipt (receipts_root). A fill produced at an epoch boundary has no transaction, so it commits through the block header's epoch_fills_root. Both channels use the same Fill::serializeState() bytes; only the Merkle leaf domain tag differs ("rcpt" vs "efil"). This is what makes an epoch-boundary fill a first-class consensus event while leaving the per-transaction receipt format unchanged.

Every NFT is also a token. The NFT's shadow token is a TokenInfo with maxSupply = 1 and decimals = 0. It draws its id from the same next_token_id counter that user-created tokens draw from, so there is no reserved range, no collision class, and no second counter to keep in sync. The consequence is that every existing token mechanism — getTokenInfo, getBalance, getTokenHolders, the DEX, the AMM, the reward layer, the proof layer — works on NFTs without special-casing. A client that doesn't know about NFTs still sees them as tokens with a supply of one. A client that does know about NFTs gets the extra NftInfo record through the NFT RPC methods. This is the same "everything is a first-class state object" principle that makes the DEX and AMM simple: no VM, no contracts, no interface discovery.

Wrapped NFT custody is a virtual address, not a field. The underlying of a wrapped NFT lives at Keys::nftAddress(nft_id) in the token-balance table, which is the same balance table every other address uses. This means the custody is a real balance: it participates in proofs, historical reads, and the by-token balance index without any special case. A client that wants to see the wrapped amount calls getBalance(custody_address, token_id), which is the same call it makes for any other address. The alternative — storing the custody as a field on the NftInfo record — would require every balance query to know about NFTs, which is exactly the kind of special-casing the state layer avoids.

A wrapped NFT's custody does not move on transfer. TransferNft changes the NFT's owner and moves the shadow token, but the custody stays at the virtual address. The custody is a property of the NFT, not of its owner, and the transfer moves the right to control the custody without moving the custody itself. This is what makes an NFT a stable reference — a client that holds a pointer to the NFT's id and custody address can always find the underlying balance, regardless of who owns the NFT today.

NFTs and collections use companion records, not widened existing records. TokenInfo is a fixed-layout record; adding NFT fields to it would hard-fork every previously serialized token. Instead, NftInfo is a separate record with its own SMT key, sibling to Keys::token in the same domain-separated namespace. The shadow token is created alongside the NftInfo and they are updated together, but they have distinct codecs. This is the same pattern the key-rotation history uses (Keys::validatorKeyHistory is a companion to Keys::validator), and it is what lets a new feature land without touching the record whose codec is already in the wild.

The NFT owner index is maintained explicitly, not by convention. putNft writes the owner index row for the new owner. unindexNft removes the owner index row for an old owner. A transfer calls both, in that order, so the previous owner's forEachNftForOwner no longer returns the NFT after the transfer. This is the same explicit-index discipline the order book uses (addToOrderBook and removeFromOrderBook are separate calls, not side effects of putOrder). It is what makes the index's semantics clear: the owner index says "this NFT is currently owned by this address", nothing more.

putNft does not read the old record. Every putX in StateAccess is a pure write. The cleanup of an old record's index rows is the caller's job, exposed as a separate unindex method. This rule emerged from a hard-to-diagnose bug in NFT development: an early putNft read the old record first to unindex it, and that read — interleaved with the tree's content-addressed node update buffer — could cause a subsequent update in the same or a later transaction to be silently dropped. The fix was to make putNft a pure write and move the read to the caller, which is where every other put-method already had it. The rule is documented on putNft so it is not reintroduced.

NFT reward accrual is per-collection, not per-NFT. A collection with a reward schedule decrements its escrow once per epoch by minted_count * reward_rate_per_epoch. A per-NFT schedule would require one write per NFT per epoch, which is unbounded. A per-collection schedule requires one write per collection per epoch, which is bounded by the number of collections on the chain. The trade-off is that every NFT in a collection accrues at the same rate. For v1 that is the correct default — the most common reward program is a uniform rate across a whole collection — and a per-NFT override is additive if demand ever warrants it. The epoch-boundary pass is O(collections), not O(NFTs): a chain with 1,000,000 NFTs across 100 collections pays 100 SMT writes per epoch boundary, not a million. The pass runs before the staker early-return in processEpochBoundary, because the escrow decrement is independent of whether stakers were paid — if it ran after the return, a chain with an empty pot or no stakers would never decrement any collection's escrow, and NFT reward schedules would silently stall.

NFT reward accrual uses accrue-then-claim, not pay-per-epoch. The epoch boundary decrements the collection's escrow but does not credit any holder. The holder's pending_rewards bucket is credited at claim time, from the accrual computed against the NFT's last_accrual_height. This is the same model staking rewards use. The alternative — pushing rewards to holders at the epoch boundary — would cost one write per holder per epoch, which is unbounded. Accrue-then-claim costs one write per claim, which is bounded by activity. The invariant that makes the model sound is that the escrow is the single source of truth: the sum of all pending claims is always ≤ the total the escrow has decremented for, because the epoch boundary is the only place the escrow changes and every NFT's last_accrual_height advances only when the holder acts.

The NFT reward pass runs before the staker early-return. processEpochBoundary has an early-return when no stakers were paid this epoch (adj.distributed == 0 || total_staked == 0 || staker_count == 0). The NFT reward pass is placed above that return, so it runs unconditionally. If it were below, a chain with an empty pot or no stakers would never decrement any collection's escrow, and NFT reward schedules would silently stall. This is a general principle: an independent bookkeeping pass should not be gated on an unrelated pass's early-return.

The consensus key history is a companion SMT key, not a widened record. ValidatorInfo is a fixed 249-byte record with a stable wire layout. Adding a history field would widen it and hard-fork every previously serialized record. The history instead lives under a separate SMT key (Keys::validatorKeyHistory), sibling to Keys::validator and Keys::validatorByAddress in the same domain-separated namespace. The record's consensus_key field is a cache of the current key; the history is the source of truth. This is the same pattern as Keys::orderSequence for the book sequence companion, and it's what lets the feature land without a codec change.

Key verification is height-scoped, not current-key-scoped. A vote or equivocation proof at height H verifies against the key that was authoritative at H, not against the validator's current key. This is what makes rotation compatible with the slashing machinery: a validator that rotates its consensus key does not thereby escape a pending equivocation proof against its old key, and a validator whose old key is compromised cannot be slashed for votes that key never signed. The signer_id field in a proof is the stable identifier across rotation; only the key-resolution step changes.

The history's ranges are gapless and non-overlapping. A rotation sets the previous last entry's valid_until_height to the rotation height and appends a new entry starting at the same height. This invariant is what makes the height-scoped lookup well-defined: every height in a validator's life has exactly one authoritative key, and no height verifies against two. If the ranges ever overlapped, an equivocation proof would be ambiguous about which key to check against, which would create a slashing safety hole.

Rotation is immediate, with no grace period. The moment a RotateConsensusKey transaction commits, the old key is invalid for new signatures and the new key is authoritative. A grace period during which both keys could sign would make the height-scoped lookup ambiguous — a vote at a height in the overlap window would need to be checked against both keys, and an equivocation proof would lose the property that a single key is authoritative at a single height. Immediate rotation is unforgiving (a validator that rotates and then has a signing problem cannot fall back to the old key) but preserves the invariant.

Rotation is rejected for pending-unbond validators. A validator that has started unregistering should not extend its signing window. Allowing rotation during unbond would let a departing validator rotate to a fresh key and sign votes for a longer period than the unbond delay is designed to permit; more importantly, it would complicate the slashing window, which is precisely the period in which the unbond delay guarantees the validator remains slashable. The check is validator.pending_unbond_height != 0.

The key history is initialized at genesis and at registration. Seeds get a single-entry history from height 0. Every other validator gets a single-entry history from its registered_at_height. validatorKeyAtHeight on a validator with no history falls back to effectiveConsensusKey(), which is the same answer the pre-feature code would have given — so a validator whose history write was somehow missed still verifies correctly, and a legacy record decodes and verifies without any migration step.

Genesis timestamps are fixed constants. GENESIS_TIMESTAMP_MS = 1767225600000ULL (2026-01-01T00:00:00Z) is written into every network's genesis. Block timestamps are proposer-set and validated against now + 2 seconds.

Timer-driven poll loops. Both the consensus poll and the sync tick use boost::asio::steady_timer rather than sleep_for inside a posted lambda. The io_context stays free between ticks, so incoming messages are serviced immediately instead of after the current sleep finishes.

Proposals for future rounds are queued. A proposal for round N+1 arriving at a validator in round N is stashed and delivered when the local round advances, rather than dropped. This is what makes the round-crossing case (a validator that timed out in round N while a proposer moved on to N+1) work on a real network with jitter. A precommit for the locked block is processed at any step at or after Prevote, not only at Precommit, so a validator that locked on X in round N and is still in Propose in round N+1 will record a precommit quorum for X and commit. This is the round-crossing lock case.

Messages are weighted for rate limiting. The per-peer token bucket charges by message type: cheap control messages cost 1, Tx costs 5, GetHeaders and GetBlocks cost 20, GetProof costs 50. This reflects the asymmetry of work -- a GetHeaders is 12 bytes on the wire but causes the server to do up to 2000 lookups, and a GetProof causes a tree walk. Consensus messages use a separate, stricter bucket.

Slashing is automatic, not voted. A proof of equivocation is a mathematical fact -- two valid signatures over conflicting values at the same (height, round). It isn't subject to a vote, because a Byzantine majority could vote to slash an honest validator but cannot forge a signature. Automatic application means the safety argument of BFT is economically enforced: a validator that equivocates loses stake and earnings, regardless of what the other validators think about it.

Evidence is erased on commit, by every node. When a block containing a Slash tx commits, every node scans the block's transactions, decodes each Slash tx, and erases matching evidence from its own pending list. Not just the proposer -- every node. Only the proposer knows what it tried to include; a non-proposer only sees the block. Reading the block as the source of truth is what keeps all nodes in sync. Evidence is recorded only when both votes' signatures verify. A forged conflict is dropped rather than stored, because unverifiable evidence poisons the proposer's buffer and blocks it from proposing.

Policy rejection is not a block-level error. A system transaction whose target is ineligible for the operation -- a Slash tx against a seed, an unregistered validator, or a validator with nothing left to slash -- is a no-op at the transaction level, not a block rejection. The distinction is load-bearing: if ineligibility rejected the block, a proposer could halt the chain by including one such transaction in every block it produced. Validity of the evidence is enforced upstream (verifyEquivocationProof); the executor only applies policy. The same principle applies to every future system-tx type.

AMM pools auto-close on full drain. When the last LP withdraws and both reserves reach zero, the pool record is deleted rather than left in a zero-reserve state. A pool in that state is functionally inert -- Swap divides by a zero reserve, AddLiquidity rejects it -- and leaving the record behind would make callers special-case a state they can never usefully act on. Removing the record is what makes "remove all liquidity" mean "the pool no longer exists." Historical reads are unaffected: getAtVersion on a closed pool's key still returns the pool at any version where it existed, so a block explorer can ask "what did pool 7 look like at block 1000?" after the pool has been closed.

Active orders are cancelled, not abandoned, when their pool disappears. An Active order whose pool is gone or incompatible cannot ever fill; leaving it resting forever would waste state and confuse any client that later tried to cancel it. The evaluator cancels it with the same code path used for a condition failure -- refund minus the cancel fee, all indexes cleared. This is the counterpart to "AMM pools auto-close on full drain": a pool going away is a state that the router cannot ignore, and the order is cleaned up rather than left to rot.

An Active order's limit is enforced at the AMM, not just declared. The router computes the maximum input whose effective execution price -- output over gross input, including the fee -- satisfies the order's canonical limit, and clamps the fill quantity to that maximum. An order can never fill at a price worse than it asked for, even when the pool has moved partway between the order's creation and the epoch-boundary routing. The math is closed-form and does not iterate: for a constant-product pool, the maximum input is b / L - a / (1 - f) where b and a are the pool's reserves in the direction of the swap, L is the limit price, and f is the fee fraction. The result is floored, which biases slightly below the true maximum; the effective price at the returned amount is at or above the limit, never below.

Routed AMM fills count toward the pool's volume window. executeActiveOrder updates the same rolling window that AmmPool::executeSwap updates for a user Swap transaction, using the same pre-swap reserves and the same CLRTY-equivalent conversion. A VolumeBelow condition on a pool therefore sees both user swaps and Active-order routing against the pool; the condition's threshold is comparable across pools regardless of which two tokens the pool actually trades. The alternative — excluding routed fills from the window — would have made the condition's meaning depend on the execution path, which is not a distinction the user asked for.

Proof serving is a pure additive protocol. A GetProof request carries a typed descriptor rather than a raw SMT key -- a wallet knows an address, not a Blake2b("acct" || address) digest -- so the server does the key derivation. The reply carries the proof and the root it was proven against, so the client's verifyProof(expected_root, proof) call is self-contained: no DB access, no signature checks, no trust in the server's choice of root beyond what the proof itself establishes. A wrong root produces a failing verification, not a false acceptance.

Proofs can be requested against historical versions because the tree keeps historical node rows (see Versioning). A proof against a version older than the pruning horizon returns VersionUnavailable, which the client can distinguish from KeyNotFound and Malformed. The two-call shape -- request, then verify locally -- is what makes the protocol light-client-safe: a client that doesn't want to trust the server can send the same request to two servers and accept the result only if both produce proofs that verify against the same root.

A proof's value and siblings come from one walk. proveAtRoot walks the tree once, from the given root, and captures both the leaf value and the sibling hashes during that walk. An earlier implementation read the leaf via tree.get(), which walks from the tree's current root -- the two walks could disagree, producing a proof whose value and siblings were inconsistent whenever the requested root was historical. The fix is to produce the value and the siblings from a single walk from a single root; the invariant is now documented on proveFromRoot.

The tree is canonical by construction, not by sorting. The root of a compressed radix trie is a pure function of the key set. Two nodes with the same accounts, orders, pools, and NFTs produce the same root, regardless of the order in which those objects were created. This is what makes consensus on a state root possible without a canonical-ordering pass, and it is what makes the tree's simulate/apply invariant hold: a dry-run produces the same root as an apply, because both walk the same key set with the same invariants. An earlier draft that split leaves without reference to the current depth violated this and produced order-dependent roots; the fix — computing the shared prefix relative to the current position, not from bit 0 — is what restores canonicity.

Default hashes are precomputed once per process. The tree uses 257 default hashes, one per depth, for representing empty subtrees. Each is Blake2b("dflt" || 0x02 || BE16(depth) || h || h) where h is the previous depth's default, so the table is built bottom-up. Computing them lazily on first access keeps process startup fast and avoids the cost entirely for a node that never touches the tree.

Every state change writes its historical row through the same txn as the live write. The tree's putNodeImpl and putLeafDataImpl are the only functions that write to TBL_SMT_NODES/TBL_SMT_LEAVES. When a txn is bound, both the live row and the historical rows go through that txn; when no txn is bound, the autocommit path opens its own. Either way, the live and historical writes are atomic with each other — a node that committed but didn't write its history is not a possible state.

Pruning is a full-table scan, not a walk. pruneHistoryBefore(keepFrom) iterates the *_BY_VER tables in order and stops at the first version at or above keepFrom. Because the by-version tables are keyed by (version, hash), an ascending scan visits versions in order, and the early stop bounds the work to the pruned range. The corresponding *_HIST rows are deleted by reconstructing their keys from the by-version entries, so both tables stay in sync. A scan of the historical tables would be O(all history), which is why the by-version index exists.

The historical prune runs inside the commit transaction. applyBlock opens a write txn at the top, applies the block's transactions, writes the block index and head, and then calls pruneHistoryBeforeTxn before the final txn.commit(). A failure to prune aborts the entire block commit; the engine's retry mechanism then retries the block. This is deliberate: a storage failure that prevents pruning is a real problem, and silently continuing would let the historical tables grow unbounded behind a warning. Better to halt and let the operator fix it.

Seed rewards are stipend-based, not fee-based. When the active set contains only seeds, the producer bonus is split evenly across seeds and the set share is routed to the pot. The producing seed earns no more than any other seed -- seeds are a single operational entity, and the distinction between producing and signing is bookkeeping noise. This keeps a young chain's rewards flowing into the pot (where they eventually pay stakers) rather than concentrating in the seed operators' balances, and it preserves the incentive for new validators to register: the moment a non-seed joins the active set, normal distribution resumes.

Consensus key and reward address are separate, and derived from one mnemonic. A validator's signing identity and its payout destination are two different things with two different lifecycles. Both are derived from the same BIP-39 mnemonic at distinct paths -- m/44'/9000'/0'/0'/0' for the reward address, m/44'/9000'/0'/2'/0' for the consensus key -- so one backup covers both, and one restore regenerates both. The signing key is mutable through RotateConsensusKey; the reward address is mutable through UpdateRewardAddress. They never rotate each other. This split is what makes UpdateRewardAddress safe -- before it, changing the reward address silently rotated the consensus key, which is a live-key hazard.

Unbonding delay for departing validators. Unregistering no longer deletes a validator's record or refunds its stake immediately. Instead, the validator leaves the active set, and its record, address index, key history, and stake persist for UNBONDING_PERIOD (120 blocks, two rotation intervals) before the stake is returned. The delay serves two purposes: it makes a validator that equivocates and immediately unregisters still slashable for the full window in which a proof can be observed and included in a block, and it prevents a departing validator from being re-promoted into the active set while its exit is in flight. canBeActive() requires pending_unbond_height == 0, so rotation and offline removal both skip a pending-unbond validator automatically. The key history's lifetime matches the record's: both are deleted together at unbond expiry.

Historical state is content-addressed and deduplicated. The tree stores a historical row for every (node-hash, version) pair that was written -- that is, for every node whose subtree actually changed at that version. A subtree that did not change is stored once, at the version where it first appeared, and remains reachable from every later version's root because its hash is the same. Combined with the retention window, this means the historical tables grow with state changes within the window, not with block count. Version 0 is the bootstrap version and is not stored -- it's the state before any block has been applied, and nothing queries it.

Persistent mempool. The mempool writes through to a dedicated MDBX table (TBL_MEMPOOL) on every mutation -- add, remove, removeIncluded, purgeExpired, clear. On startup, Node::initMempool rebuilds the pool by re-adding each persisted entry through the normal validation path; entries that fail (nonce too low, insufficient funds, expired, already on chain) are dropped and their rows deleted. This means a transaction submitted before a restart is still in the pool after the node comes back -- important for the case where a user submits a tx and the operator restarts their validator before the next block commits it. The persisted entry carries the full Entry (tx, tier, fee rate, sequence, add time), not just the transaction, so priority status and eviction ordering survive a restart.

Command-line consensus key. The daemon reads the validator's consensus key from --consensus-key <hex>. This is a development convenience for the regtest devnet, where spinning up a two-validator network from a shell script should take a few commands and no persistent state. For a production validator, a file-based flag (--consensus-key-file) with restrictive permissions is the recommended path and will be added before launch. The command-line form is documented in --help as dev-only.

AuthReady is the synchronization point for encryption. The two Auth messages cross on the wire, so a peer that finishes processing Auth may not yet have received the peer's Auth -- meaning it may not yet know the peer's ephemeral key, and therefore cannot yet derive the session key. The fix is a fourth handshake message, AuthReady, sent only after both Auth messages have been sent and verified. Neither side encrypts anything before sending AuthReady, and neither accepts anything encrypted before receiving the peer's. The message itself is empty; its AEAD tag is the confirmation that the two sides derived the same key from the same inputs. A wrong key produces a failing AEAD, not a false acceptance, so key disagreement is detected at the earliest possible point and before any application data flows.

Directional session keys, not a shared one. The session key agreement produces two keys, not one. The lower-nonce side sends under one and receives under the other; the higher-nonce side does the reverse. This is not strictly necessary for confidentiality -- a single shared key with a per-direction nonce counter is also safe -- but it removes the class of bug where the two directions accidentally share a nonce space. The AAD additionally binds a direction tag, so a frame reflected back to its sender fails authentication even if the nonce and key happened to collide. The cost is one extra Blake2b call per session, which is free in any accounting that matters.

The aggregate rate-limiter cost table is deliberately not messageCost. The per-peer bucket assigns GetProof a cost of 50 because serving one proof is roughly 50× a control message. At the aggregate level that ratio is wrong: a single legitimate proof request would consume 20% of a modest aggregate budget and starve header sync. The aggregate table compresses the ratio -- GetProof is 10, GetHeaders/GetBlocks are 5, relay and consensus types are 3, control is 1 -- so expensive work is shed first without letting one legitimate request dominate. The two tables are in separate translation units (MessageTypes.cpp and AggregateLimiter.cpp) so a future edit to one cannot silently change the other.

Metrics is a pull, not a push. The node never sends metrics anywhere. A Prometheus-compatible scraper asks for them, and the node answers. That means the metrics endpoint is bounded by what a scraper can request, not by what the node chooses to send — an unreachable monitoring system causes zero work at the node. The alternative, push-based metrics, adds failure modes (what happens when the push target is down? buffer? drop? back off?) that a scrape-driven endpoint simply doesn't have. This is not a novel design; it's the same reason Prometheus exists, and Bitcoin Core, geth, and Erigon all follow it.

The scrape path reads through Node::status(), not from the event loop. Every metric the handler emits comes from one of four sources: Node::Status (a value struct), the memory-mapped database (through StateAccess), lock-free atomics on P2PManager, or value-returning accessors on Mempool and BftConsensus that take their own short-lived mutexes. None of them requires posting a request to the P2P event loop and waiting for a reply. That's the property that keeps a metrics scrape from ever blocking on message handling, and it's why P2PManager::snapshot() exists — it reads atomic counters rather than iterating the peer table, which is only safe from the event loop. P2PManager::peerList(), which does post to the event loop, is deliberately not called by the metrics handler.

Each handler owns its own method contract. The HTTP transport layer (HttpConnection) validates only what's universal: the HTTP version, the method is one the server speaks at all (GET or POST), and the request size is under the global limit. Everything else — which method is valid for which path, whether a body is required, what content type is acceptable — is the handler's job. This split is what makes POST /metrics return 405 use GET for metrics from the metrics handler rather than 400 Content-Length is required from the transport. The first is the correct diagnosis; the second is a true statement about a request whose real problem is that it targeted the wrong handler. JsonRpcDispatcher and MetricsHandler both implement IHttpHandler, both check their own method contract, and both return their own error responses for violations.

clrty_ is a namespace, not decoration. Every metric name carries the clrty_ prefix. Prometheus treats metric names as a flat global namespace across every exporter the server scrapes — node_, postgres_, nginx_, ethereum_, and so on all live in the same bag. A metric named height would collide with height from any of a dozen other exporters, and the operator would have to disambiguate every query with {job="clarity"}. The prefix costs a few bytes per scrape (compressed on the wire by Prometheus's default gzip) and saves the operator from writing longer queries forever. This is the same reason every well-behaved Prometheus exporter in existence prefixes its metrics.

Read transactions are held, not opened per lookup. A naive implementation opens a fresh MDBX read transaction for every get. That works but scales poorly: a tree walk is dozens of reader-slot acquisitions, and under concurrent RPC load a small reader table (MDBX's default is 126) can be exhausted, causing lookups to fail with MDBX_BUSY and — worse — be misinterpreted as "not found" by the caller. The fix is twofold. First, StateAccess in autocommit mode owns a single read transaction for its lifetime, and its tree is bound to that transaction. Second, a per-thread handle (StateDB::tls_read_txn_) is checked before every raw read, so a nested read from inside a visitor reuses the outer transaction rather than opening a second one. The combination means a tree walk is one reader slot, and a whole RPC handler that does a dozen lookups is one reader slot.

Every write releases the held read transaction first. MDBX refuses to open a write transaction on a thread with an open read transaction. The state layer's write methods therefore call releaseReadTxnIfHeld() before delegating to rawPut/rawDel. This makes the common case — a StateAccess that reads a value, writes a value, and reads another value — work transparently. The cost is one extra transaction open/abort per read-write transition, which is dwarfed by the write itself.

Only non-SMT writes may be gated on dry_run. The block processor's dry-run path aborts its write transaction at the end, so any state change it makes is discarded. If a change is gated on !ctx.dry_run, the dry-run path doesn't make it and the two paths produce different state roots. The rule is: SMT writes happen unconditionally; meta-table and non-SMT index writes may be gated. This was the source of a hard-to-diagnose bug where a block's simulate and apply paths disagreed on the state root.

Commit failure retries in place, then halts. A failed on_block_committed is not treated as a failed round. The block already has 2f+1 precommits; re-proposing a different block would not help, and would push a validator with a persistent apply failure toward emergency rotation — the worst possible response to a local disk problem. Instead the engine retries the same block with a linear backoff, up to max_commit_retries, and if every retry fails it halts: running_ = false, halted_ = true, on_consensus_halt fired once. No height advance, no WAL truncation, no pending_height_. This makes the consensus engine's view of the chain consistent with the actual chain head — the engine stays parked on the block it cannot apply rather than silently marching forward — and gives operators a loud, observable signal (halted(), on_consensus_halt) that something is wrong with the node's storage rather than with consensus. A node whose storage is fixed, or whose chain head was moved by relay or sync, calls noteChainHead to unstick the engine without a restart.

The consensus WAL is wiped on fresh genesis. A fresh DB is a fresh chain; any WAL rows from a previous life reference blocks whose height and hash no longer correspond to anything on the current chain. Node::initGenesis calls state_db_->wipeConsensusWal() after writing genesis on the fresh-DB path. Without it, the consensus engine replays its own votes for a chain that no longer exists and forces the validator to be loyal to a block that no other node recognises. On an existing DB, the WAL survives a restart — that's the crash-recovery case the WAL exists for.

Genesis is one atomic write. The entire genesis state, the genesis block, and the chain head all live in a single MDBX write transaction opened at the start of Node::initGenesis. Either all of it commits or none of it does. Autocommit StateAccess was previously used on this path, which meant genesis was written one row at a time — a crash partway through left a partially-applied genesis that a restart couldn't detect or repair.

Historical pruning is not throttled. An earlier design ran the prune every 100 blocks to amortize its cost. With a bounded retention window — 64, 512, or 2048 versions depending on network — the amount of work per prune is small enough that it runs every block. Running every block makes the retention window exact: after block N commits, the oldest retained version is exactly N - smt_history_blocks, never a few more. This is a small efficiency loss for a meaningful correctness gain.

The retention window is a per-node choice, not a consensus parameter. Two nodes with different smt_history_blocks values still agree on the chain, on state roots, and on every block. Pruning only affects which historical versions a node can serve proofs against, and that is a local query. A node that prunes aggressively can still serve proofs against the current root and any version inside its window; a node that keeps everything can serve proofs against any version since genesis. The two nodes have identical consensus behavior; they differ only in what historical queries they can answer.

About

"Authority rotates, the chain doesn't." - Proof of Rotating Authority (PoRA), immediate finality, sparse Merkle state with historical proofs, tokens and NFts, AMM, limit orders, two-key validators, epoch rewards.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages