All pages

State and blocks

How a node stores and authenticates its data: the state map and its keys, the Jellyfish Merkle Tree, the RocksDB layout, the block format and its three roots, genesis, finality proofs, state pruning and account proofs. This is a byte-level reference for protocol engineers, auditors and anyone writing a light client. Byte counts and hashes on this page were taken from chains run with the 0.2.3 binaries. Block, transaction and genesis hashes were recomputed outside the node from the raw bytes, and they matched.

State model

State is a map from byte strings to byte strings. Executing a block produces a WriteSet, and applying it creates one new version of the map. The version number is the block height, and version 0 is genesis. Genesis is state and not a block, so block heights start at 1.

pub type WriteSet = BTreeMap<Vec<u8>, Option<Vec<u8>>>; // None deletes the key

pub trait StateReader {
    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, Error>;
}

pub struct MemoryState {
    entries: BTreeMap<Vec<u8>, Vec<u8>>,
}

pub struct Overlay<'a, S: StateReader + ?Sized> {
    base: &'a S,
    layers: Vec<&'a WriteSet>,
}

pub struct Error(pub String);
ReaderReads fromUsed for
MemoryStatea BTreeMap, changed with apply(&WriteSet)tests and tools; needs no database
Overlaylayers newest first, then base; a layer holding None answers "absent" and stops the searchexecuting on top of uncommitted parents
Storejmt_values at the latest heighteverything on a node
&Tforwards to Tpassing readers by reference

qubits-state has one feature, rocksdb, on by default. Without it the crate exports only the trait, WriteSet, MemoryState, Overlay and Error.

State keys

qubits_execution::keys is the whole key space. The tree hashes keys, so the prefixes have no effect on tree position. They separate the namespaces.

KeyValueNotes
account/ then 32-byte addressborsh Accountsee Accounts
validatorsborsh ValidatorSet { epoch: u64, validators: Vec<Validator> }see Validators and epochs
rewardsborsh RewardTotalsabsent until the first fee, see Rewards
reward/ then 32-byte addressborsh RewardAccountwritten when a payout address joins, leaves or claims
contractsborsh Vec<Address>, in deployment orderin state, and not an index beside it, so a node that joined from a snapshot has it
contract/ then 32-byte addressborsh Contract { code: Hash }the code lives once under its hash
code/ then 32-byte code hashthe Wasm module, raw
storage/ then 32-byte address then the namethe value, rawthe address has a fixed length and comes first, so one contract can't reach another's keys
pub struct Account {
    pub nonce: u64,
    pub balances: BTreeMap<AssetId, Amount>,
    pub auth: Option<AuthPolicy>,
    pub vault: Option<VaultPolicy>,
    pub pending: Vec<PendingAction>,
    pub next_pending_id: u64,
}

Sizes of account/ values read from a real chain's leaves: 46 bytes for an account that has only received, 2,008 bytes for one that revealed a single ML-DSA-65 policy.

Only contract storage writes produce a None in a WriteSet. Accounts, the validator set, code and contract records are never deleted.

Jellyfish Merkle Tree

The tree is the jmt crate, version 0.12.0, with default-features = false and std. Blake3Hasher plugs BLAKE3 in through jmt::SimpleHasher:

impl SimpleHasher for Blake3Hasher {
    fn new() -> Self {
        Blake3Hasher(blake3::Hasher::new_derive_key("qubits.sh 2026-11 state tree"))
    }
    fn update(&mut self, data: &[u8]) { self.0.update(data); }
    fn finalize(self) -> [u8; 32] { *self.0.finalize().as_bytes() }
}

Write H(x) for BLAKE3 in derive-key mode with the context qubits.sh 2026-11 state tree, 32 bytes of output. Each hash below is one H, with the parts concatenated.

ItemDefinition
Key hashH(key)
Value hashH(value)
Leaf nodeH("JMT::LeafNode" ‖ key_hash ‖ value_hash)
Internal nodeH("JMT::IntrnalNode" ‖ left ‖ right)
Empty subtreethe 32 ASCII bytes SPARSE_MERKLE_PLACEHOLDER_HASH__, not hashed

The tree is sparse over 256-bit key hashes. Hashing is binary, one bit per level, from the first bit of the key hash at the root down. Storage is not: a stored internal node compresses four binary levels into one node of up to 16 children, so a node's address is a nibble path. A leaf sits as high as its key hash stays unique, and siblings exist only down to that depth.

A root depends on the contents and not on the order of writes. Store::speculative_root and sequential commits give the same root, and a tree rebuilt from a snapshot must reproduce the certified root exactly.

Database layout

Store wraps one RocksDB database in --data-dir with nine column families. Store::open sets create_if_missing and create_missing_column_families. Every column family uses Options::default(), and the code sets no compression, compaction or cache options. The rocksdb crate is 0.25.0, on RocksDB 11.8.1.

Column familyKeyValue
jmt_nodesborsh NodeKeyborsh jmt::storage::Node
jmt_valueskey hash (32), version as u64 big-endian (8): 40 bytesborsh Option<Vec<u8>>; None is a tombstone
jmt_stale_nodesstale_since_version as u64 big-endian, then borsh NodeKeyempty
jmt_stale_valuesreplacing version as u64 big-endian, key hash, replaced version as u64 big-endian: 48 bytesempty
blocksheight as u64 big-endianborsh ChainBlock
tx_indextransaction id (32)borsh TxLocation: 12 bytes
receiptstransaction id (32)borsh Receipt, only for receipts other than the default
metasee belowsee below
pending_blocksepoch, height, each u64 big-endian, then block id (32): 48 bytesborsh Proposal<BlockPayload>

Borsh NodeKey is the version as u64 little-endian, num_nibbles as u64 little-endian, then the path bytes with a u32 length. Nodes are only looked up by exact key, so the little-endian version does no harm.

Values are keyed by key hash then big-endian version, so a reverse seek from (hash, v) finds the newest value at or below v. That is how every read at a version works.

The value in blocks is exactly borsh ChainBlock, with no wrapper. Certificates are not stored with it.

pub struct TxLocation {
    pub height: u64,
    pub index: u32,
}

Meta keys

The store's own markers sit in meta under plain keys. Everything the node writes through put_meta, get_meta or CommittedBlock::meta gets the prefix node/ first.

Key in metaValueWritten
latest_heightu64 big-endianwith every tree write
genesisborsh Genesiscommit_genesis
pruned_beforeu64 big-endian; absent means 0prune, finish_restore
blocks_fromu64 big-endian; absent means 1, and reads never give less than 1prune_history, set_blocks_from, finish_restore
node/consensus_tipborsh ConsensusTipwith every block
node/consensus_finalityborsh (u64, FinalityProof): the newest height with a proof, and that proofwith a block that carries a proof
node/consensus_epochborsh EpochInfowith a block that changes the validators; absent in epoch 0
node/consensus_epochsborsh Vec<EpochInfo>, every epoch after the firstsame
node/consensus_safetyborsh SafetyRecord { epoch: u64, state: SafetyState }synced before a vote leaves
node/finality/ then height as u64 big-endianborsh FinalityProofsee Finality proofs
pub struct EpochInfo {
    pub epoch: u64,
    pub start: Hash,
    pub start_height: u64,
}

pending_blocks holds proposals that consensus asked to persist. Output::PersistBlock writes one before later outputs are acted on, Output::Committed removes everything below the committed height with a range delete, and a restart hands the rest back through Start::blocks. See Consensus.

Writing state

Tree work happens in two steps, so a root can be computed and compared before anything is stored.

prepare

Store::prepare(version, &writes) returns a PreparedState { version, root, batch } and writes nothing.

  1. version must be the next one: 0 on an empty database, otherwise latest_height + 1. Anything else fails with cannot prepare version {version}, next is {expected}.
  2. JellyfishMerkleTree::put_value_set hashes each key and computes the new nodes, the stale node index and the root.

commit_genesis

Refuses a database that has a height (database is already initialized) and a prepared state of any version but 0 (genesis state must be version 0). The batch holds the tree writes and the genesis marker.

commit_block

CommittedBlock carries what goes in with the tree:

pub struct CommittedBlock<'a> {
    pub height: u64,
    pub state_root: Hash,
    pub bytes: &'a [u8],
    pub tx_ids: &'a [Hash],
    pub receipts: &'a [(Hash, Vec<u8>)],
    pub meta: &'a [(&'a [u8], Vec<u8>)],
}
  1. Check block.height == prepared.version, or fail with block height does not match prepared state.
  2. Check block.state_root == prepared.root, or fail with block state root does not match prepared state.
  3. Check the head is height - 1, or fail with block {height} is not the next block.
  4. Build one WriteBatch:
    1. every new tree node into jmt_nodes;
    2. every stale node into jmt_stale_nodes;
    3. every new value into jmt_values, and for a key that had a value before, an entry in jmt_stale_values;
    4. latest_height;
    5. the block bytes into blocks;
    6. a TxLocation for each transaction into tx_index;
    7. the receipts into receipts;
    8. the meta entries, under node/.
  5. Write the batch with set_sync(true).

A crash leaves the whole block or none of it: state, block, indexes, tip and proof move together. put_meta, put_pending_block, delete_pending_blocks_before, prune and prune_history also write with sync, each in its own batch.

From a consensus commit to a batch

write_committed in qubits-node is the one path from a final block to the store. Consensus and sync both use it.

  1. prepare(block.height, &executed.writes).
  2. Compare the prepared root with block.payload.state_root, or fail with state root of block {height} does not match execution.
  3. Compare receipt_root(&executed.receipts) with the block's, or fail with receipts of block {height} do not match its receipt root.
  4. Build the meta entries: always consensus_tip; consensus_finality when this block has a proof; finality/<height> when it also keeps one; and for a block that changes the validators, consensus_epoch and consensus_epochs.
  5. commit_block.
  6. Read the new nonces of the block's senders, before taking the mempool lock that consensus shares. Then remove the included transactions, and any mempool transaction of those senders whose nonce is now below the account's.
  7. Log committed block.
  8. With pruning on, prune(height - keep). This is a second synced write and not part of the block's batch.

Speculative execution

Validators execute a block when it is proposed, on top of parents that are not final yet, so they can check its roots before voting. Nothing of this reaches the database until the block commits.

ChainApp keeps a Speculative record per verified block: its parent, height, timestamp, Executed { writes, receipts }, whether it reconfigures, and its transaction ids.

To execute a block on parent:

  1. layers(parent) walks from parent back to the last committed block and collects each Speculative's writes, oldest first. An unknown parent fails.
  2. Overlay::new(store, layers) gives the executor a view of that chain.
  3. Executor::prewarm checks signatures on all cores, then transactions apply one at a time.
  4. Store::speculative_root(&chain) takes the layers plus this block's writes. It calls put_value_sets(sets, base + 1), which computes every version in memory through the tree cache, keeps the last root and discards the batch. With an empty chain it returns the root at the head.

A proposer runs this with strict = false: transactions that fail are skipped and dropped from the mempool, ones that hit TxError::BlockGasFull stay in it for the next block, ones that would pass MAX_BLOCK_BYTES are skipped, and the loop ends at max_block_transactions. A verifier runs it with strict = true: any of those fails the block.

The commit path does not reuse these roots. write_committed runs prepare again on the real store and compares.

Blocks

pub type ChainBlock = Block<BlockPayload>;

pub struct Block<P> {
    pub round: Round,                 // u64
    pub height: u64,
    pub parent: Hash,
    pub proposer: ValidatorIndex,     // u16
    pub voters: Vec<ValidatorIndex>,
    pub payload: P,
}

pub struct BlockPayload {
    pub timestamp_ms: u64,
    pub transactions: Vec<SignedTransaction>,
    pub tx_root: Hash,
    pub state_root: Hash,
    pub receipt_root: Hash,
}

pub struct Header {
    pub round: Round,
    pub height: u64,
    pub parent: Hash,
    pub proposer: ValidatorIndex,
    pub voters: Vec<ValidatorIndex>,
    pub payload: Hash,
}

Fields are in borsh order: integers little-endian, Vec as a u32 length then the items. voters lists the validators the proposer last heard a vote from. It only feeds leader selection, and nothing proves it. See Consensus.

Blocks carry no certificate. The certificate that makes a block final sits in a finality proof.

Block id

payload_hash = BLAKE3-derive("qubits.sh 2026-11 consensus payload", borsh(payload))
header       = round, height, parent, proposer, voters, payload_hash
id           = BLAKE3-derive("qubits.sh 2026-11 consensus block", borsh(header))

The parent of block 1 is the genesis hash, and in /v1/status the latest_hash of a chain at height 0 is the genesis hash too. The first block of a later epoch has the block that ended the epoch before as its parent, so one hash chain runs through all epochs. What changes at an epoch is the chain value signatures are bound to.

Block rules

RuleValueWhere
Timestamp above the parent'sa proposer uses max(now, parent + 1)ChainApp::propose, verify
Timestamp not ahead of the verifier's clockat most MAX_CLOCK_DRIFT_MS, 10,000 msChainApp::verify
Transactions per blockat most params.max_block_transactionsgenesis
Bytes of transactions per blockMAX_BLOCK_BYTES, 8 MiBlimits
Sum of gas limits per blockMAX_BLOCK_GAS, 10,000,000limits
Transaction sizeMAX_TX_BYTES, 288 KiBlimits
After a block that changes the validators, until it is finalno transactionsChainApp::verify

Worked example

An empty block, height 64, from a four-validator chain, 170 bytes as returned by /v1/blocks/64/raw:

offset  size  field          value
0       8     round          64
8       8     height         64
16      32    parent         4f03e0604201da36711245ab8d376550d43f0af649853874abe663c9baeba439
48      2     proposer       0
50      4     voters length  4
54      8     voters         0, 1, 2, 3
62      8     timestamp_ms   1794015315873
70      4     transactions   0
74      32    tx_root        32 zero bytes
106     32    state_root     1262d4ae6c1dc12e07582de8c1ac8074f97dce0dec2d3c76a0d17c6f21153d6d
138     32    receipt_root   32 zero bytes

Bytes 62 to 169 are the payload, 108 bytes, and hash to c22ab245706ddbe4db1416e55e58717ac0d3c098f6f4eb0d276ad72cb7cedd79. Bytes 0 to 61 followed by that hash are the 94-byte header, and that hashes to the block id abd18388615e16dc00830de98b6eeb8c33834782d9c25781c7ef880bf5f45562.

Roots

tx_root and receipt_root

Both use one binary Merkle tree:

  1. No leaves: the root is Hash::ZERO, 32 zero bytes.
  2. While more than one hash is left, take them in pairs, left to right, and replace each pair with digest(node_context, left ‖ right).
  3. An odd hash at the end moves up unchanged. It is not paired with itself.
RootLeafLeaf contextNode context
tx_rootdigest(leaf_context, borsh(SignedTransaction))qubits.sh 2026-11 tx root leafqubits.sh 2026-11 tx root node
receipt_rootdigest(leaf_context, borsh(Receipt))qubits.sh 2026-11 receipt root leafqubits.sh 2026-11 receipt root node

tx_root covers the signatures and any revealed policy, because its leaf is the whole SignedTransaction. The transaction id does not: it is digest("qubits.sh 2026-11 transaction id", borsh(Transaction)), the signed part only.

Receipts have one leaf per transaction, in block order. State alone can't say why a call did nothing, which is why the block commits to them.

pub struct Receipt {
    pub outcome: Outcome,
    pub gas_used: u64,
    pub output: Vec<u8>,
    pub events: Vec<Event>,
    pub contract: Option<Address>, // the address a deployment created
}

pub enum Outcome {
    Done,
    Reverted { reason: Vec<u8> },
    OutOfGas,
    Trapped,
    Invalid,
}

pub struct Event {
    pub contract: Address,
    pub topic: Vec<u8>,
    pub data: Vec<u8>,
}

A plain transfer has Receipt::default(), which encodes to 18 bytes: outcome tag, gas_used, two empty lists and an empty option. It still counts as a leaf, so receipt_root is non-zero for any block with transactions. write_committed stores only receipts that differ from the default, and /v1/transactions/{id} answers null for the rest.

The roots of a 17-transfer block were recomputed from its raw bytes with these rules, and they matched.

state_root

The root of the tree at version height, after that block's writes. A block with no state changes has the same root as its parent. Unlike the other two roots, it is not defined over the block's own contents: to check it you need the state before the block and the rules of execution.

Genesis

pub struct ChainParams {
    pub min_fee_per_byte: u64,
    pub max_block_transactions: u32,
}

pub struct GenesisAccount {
    pub address: Address,
    pub balance: Amount, // u128; a decimal string in JSON
}

pub struct Genesis {
    pub chain_id: String,
    pub timestamp_ms: u64,
    pub params: ChainParams,
    pub validators: Vec<PublicKey>,
    pub accounts: Vec<GenesisAccount>,
}

All three structs set deny_unknown_fields. The genesis hash is digest("qubits.sh 2026-11 genesis", borsh(genesis)), field by field in the order above. It is the id every transaction signs and the chain of epoch 0.

{
  "chain_id": "qubits-example",
  "timestamp_ms": 1794000000000,
  "params": {
    "min_fee_per_byte": 10000000000,
    "max_block_transactions": 1000
  },
  "validators": [
    {
      "key": "016910d647238d8e886c030eb9e0fdb69c6b2b96aeea1722bd5be623c77f09d0...",
      "payout": "qb12es0z0sgfpjavxrstvugncktya46nn6d9qdv0wpt320cpr54m6gs6j99p4"
    }
  ],
  "accounts": [
    {
      "address": "qb12es0z0sgfpjavxrstvugncktya46nn6d9qdv0wpt320cpr54m6gs6j99p4",
      "balance": "1000000000000000000000"
    },
    {
      "address": "qb157cmgrg86jcm5t46njx2l85ly6tx3udv84mwm4gqjsl92hmqguzszhfztx",
      "balance": "1000000000000000000"
    }
  ]
}

This file, with the key written out in full, was run through qubits-node init. A key in validators is the hex of the tagged public key from qubits key show: one scheme byte and 1,952 key bytes, 3,906 hex characters. The second account here is the governance address. It needs funds because governance pays fees like any account.

Genesis::validate runs before the data directory is opened. Errors come back as configuration: genesis: decoding failed: ....

CheckError
chain_id is 1 to 64 characters of a-z, A-Z, 0-9 and -chain_id must be 1-64 characters of a-z, A-Z, 0-9 and -
1 to 50 validatorsgenesis needs 1 to 50 validators
no duplicate keysduplicate validator key
every key is ML-DSA-65validators must use ML-DSA-65 keys
max_block_transactions above 0max_block_transactions must be positive
no duplicate addressduplicate genesis account {address}
balances sum to at most u128::MAXtotal supply overflows

min_fee_per_byte and timestamp_ms are not range-checked. min_fee_per_byte is in base units, of which a token has 10^18.

genesis_writes builds version 0:

  1. For each genesis account, account/<address> with Account::default() and the balance.
  2. account/<governance_address> gets auth set to the council of the validators, an M-of-N policy with M = n minus f. Its balance, if listed, stays.
  3. validators with ValidatorSet { epoch: 0, keys }.

A genesis account has no auth. Its first transaction reveals its policy, see Accounts.

qubits-node init refuses a directory that already holds a chain, with {dir} already holds a chain.

Finality proofs

A proof that a block is final:

pub struct FinalityProof {
    pub tip: Hash,
    pub path: Vec<Header>,
    pub qc: QuorumCert,
}

pub struct QuorumCert {
    pub block: Hash,
    pub round: Round,
    pub kind: VoteKind, // Optimistic, Normal or Fallback
    pub votes: Vec<(ValidatorIndex, Signature)>,
}

FinalityProof::verify(tip_round, validators, chain):

  1. Walk path from tip. Each header's parent must be the id of the one before.
  2. Require at least one header whose round is exactly one above the previous round. The tip's own round counts as the first.
  3. The last header must be what qc certifies, in block and in round.
  4. qc must verify against validators and chain: at least a quorum of signatures, signer indexes strictly rising. An empty path never verifies.

Every ancestor of a certified block is certified too, because honest validators vote only after seeing the parent's certificate. So one certificate above any two consecutive rounds proves everything below it, and blocks don't each need their own. See Consensus for why.

What is kept

pub const PROOF_EVERY: u64 = 64;

pub struct ConsensusTip {
    pub id: Hash,
    pub height: u64,
    pub round: Round,
    pub qc: QuorumCert,
    pub timestamp_ms: u64,
}
RecordKeyWritten for
Tip recordconsensus_tipevery committed block, overwritten
Tip proofconsensus_finalityany block committed with a proof, overwritten; consensus gives one to the last block of every committed run
Kept prooffinality/<height>heights divisible by PROOF_EVERY, and every block that changes the validators

A node restarts from ConsensusTip: the id, height and round say where the chain stands, and qc is the high QC. consensus_finality proves the tip to followers.

Certificates are too big to keep per block. Sizes follow from QC = 45 + q × 3316 bytes for quorum q, and proof = 36 + path headers + QC, where a header is 86 + 2 × voters bytes. These match the proofs measured at n = 1 and n = 4.

ValidatorsQuorumQC bytesProof bytes with one header
113,3613,485 (measured)
439,99310,123 (measured)
7516,62516,761
5034112,789113,011

Proving any height

Node::finality_proof(height), which /v1/blocks/{h}/raw?prove=true calls when no proof is kept for h:

  1. Load block height, and the tip proof from consensus_finality. If either is missing, there is no proof yet.
  2. Walk h from height up to the tip height. If h has a kept proof, or is the tip, stop there.
  3. Otherwise add block h + 1's header to path.
  4. Return FinalityProof { tip: id of block height, path: the headers plus the kept proof's path, qc: the kept proof's qc }.

So any held height needs at most 63 headers of its own on top of the next proof. On the four-validator chain, with the head at 103, block 65 was proven through the tip proof with 39 headers in 13,695 bytes.

History expiry drops kept proofs with their blocks, see Sync and history.

State pruning

Without --prune-keep, a node keeps every version. With --prune-keep N, write_committed calls prune(height - N) after each block above height N, and the node keeps versions height - N to height. A measured node at height 357 with --prune-keep 100 reports state_from: 257. Blocks are not touched. The flag refuses values below 100.

A version is a set of tree nodes and values that it shares with its neighbours, so pruning needs to know when each piece stopped being used. That is recorded at commit time:

  • Nodes. put_value_set returns a stale_node_index_batch: each node replaced at this version, with stale_since_version and its NodeKey. write_tree adds one entry per node to jmt_stale_nodes.
  • Values. For each new value at version v > 0, latest_value_version(hash, v - 1) finds the version it replaces. If there is one, write_tree adds (v, hash, previous) to jmt_stale_values.

Both indexes sort by big-endian version first, so a scan from the start sees the oldest entries first.

Store::prune(keep_from):

  1. Clamp keep_from to the latest height. If it is not above pruned_before, return 0.
  2. Scan jmt_stale_nodes from the start. For every entry with stale_since_version <= keep_from, delete the node and the entry. Stop at the first entry above.
  3. Do the same for jmt_stale_values, deleting the value at the replaced version.
  4. Set pruned_before = keep_from.
  5. Write all of it as one synced batch.

Something that stopped being used at version v is needed only by versions below v, so deleting up to and including keep_from leaves version keep_from whole.

Tree reads at a lower version fail with state at version {v} was pruned (kept from {pruned}). state_root, get_at, get_with_proof and state_chunk all check this. The marker survives restarts, and /v1/status reports it as state_from.

Account proofs

GET /v1/proofs/accounts/{address}?height=H proves an account, or its absence, against the state root at a height. H defaults to the head.

pub struct AccountProofResponse {
    pub height: u64,
    pub block_hash: Hash,
    pub state_root: Hash,
    pub account: Option<String>, // hex of borsh Account; null if absent
    pub proof: String,           // hex of borsh SparseMerkleProof
}

The proof, in borsh, is leaf: Option<(key_hash, value_hash)>, then siblings: Vec<SparseMerkleNode>, ordered from the bottom of the tree to the root. A sibling is a tag and its contents: 0 for an empty subtree with nothing after it, 1 for an internal node with left and right, 2 for a leaf with key_hash and value_hash. Each sibling's hash is computed from those contents, so a prover can't pass a leaf off as an internal node.

To verify:

  1. Compute k = H("account/" ‖ address).
  2. If account is present, the proof must have a leaf with key_hash = k and value_hash = H(account bytes).
  3. If account is null and the proof has a leaf, that leaf's key hash must differ from k, and must share at least siblings.len() leading bits with it. That leaf then occupies the only place k could be.
  4. If account is null and the proof has no leaf, the position is empty.
  5. Start from the leaf hash, or the placeholder if there is no leaf.
  6. Number the bits of k from 0, the top bit of the first byte. With n siblings, sibling i, counted from the bottom, uses bit n - 1 - i. If the bit is 1 the sibling is on the left: cur = H("JMT::IntrnalNode" ‖ sibling ‖ cur). If it is 0: cur = H("JMT::IntrnalNode" ‖ cur ‖ sibling).
  7. cur must equal state_root.
  8. Anchor the root. Fetch /v1/blocks/{H}/raw?prove=true, decode the block and proof, and check block.id() == block_hash, FinalityProof::verify(block.round, validators, chain) and block.payload.state_root == state_root. The validators and chain are those of the block's epoch, see walking the epochs.

In Rust, steps 1 to 7 are Store::verify_proof(root, key, value, &proof).

At height=0 the answer has the genesis hash as block_hash, and no block carries that root: recompute it from the genesis file instead.

Measured against a 44-leaf state: an inclusion proof of 394 bytes with 5 siblings and a non-inclusion proof of 459 bytes with 6 siblings. An independent implementation of the steps above accepted both.

CaseStatusError
H above the head404that height is not committed yet
No block held at H404no block at that height
Block held, state pruned404state at that height was pruned
Address does not parse400the parse error

The full API is on the API page.

Source

  • chain/crates/qubits-state/src/lib.rs
  • chain/crates/qubits-state/src/store.rs
  • chain/crates/qubits-state/tests/store.rs
  • chain/crates/qubits-types/src/block.rs
  • chain/crates/qubits-types/src/contract.rs
  • chain/crates/qubits-types/src/genesis.rs
  • chain/crates/qubits-types/src/hash.rs
  • chain/crates/qubits-types/src/rpc.rs
  • chain/crates/qubits-types/src/lib.rs
  • chain/crates/qubits-consensus/src/types.rs
  • chain/crates/qubits-execution/src/lib.rs
  • chain/crates/qubits-node/src/app.rs
  • chain/crates/qubits-node/src/lib.rs
  • chain/crates/qubits-node/src/rpc.rs
  • chain/crates/qubits-node/tests/sync.rs