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);
| Reader | Reads from | Used for |
|---|---|---|
MemoryState | a BTreeMap, changed with apply(&WriteSet) | tests and tools; needs no database |
Overlay | layers newest first, then base; a layer holding None answers "absent" and stops the search | executing on top of uncommitted parents |
Store | jmt_values at the latest height | everything on a node |
&T | forwards to T | passing 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.
| Key | Value | Notes |
|---|---|---|
account/ then 32-byte address | borsh Account | see Accounts |
validators | borsh ValidatorSet { epoch: u64, validators: Vec<Validator> } | see Validators and epochs |
rewards | borsh RewardTotals | absent until the first fee, see Rewards |
reward/ then 32-byte address | borsh RewardAccount | written when a payout address joins, leaves or claims |
contracts | borsh Vec<Address>, in deployment order | in state, and not an index beside it, so a node that joined from a snapshot has it |
contract/ then 32-byte address | borsh Contract { code: Hash } | the code lives once under its hash |
code/ then 32-byte code hash | the Wasm module, raw | |
storage/ then 32-byte address then the name | the value, raw | the 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.
| Item | Definition |
|---|---|
| Key hash | H(key) |
| Value hash | H(value) |
| Leaf node | H("JMT::LeafNode" ‖ key_hash ‖ value_hash) |
| Internal node | H("JMT::IntrnalNode" ‖ left ‖ right) |
| Empty subtree | the 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 family | Key | Value |
|---|---|---|
jmt_nodes | borsh NodeKey | borsh jmt::storage::Node |
jmt_values | key hash (32), version as u64 big-endian (8): 40 bytes | borsh Option<Vec<u8>>; None is a tombstone |
jmt_stale_nodes | stale_since_version as u64 big-endian, then borsh NodeKey | empty |
jmt_stale_values | replacing version as u64 big-endian, key hash, replaced version as u64 big-endian: 48 bytes | empty |
blocks | height as u64 big-endian | borsh ChainBlock |
tx_index | transaction id (32) | borsh TxLocation: 12 bytes |
receipts | transaction id (32) | borsh Receipt, only for receipts other than the default |
meta | see below | see below |
pending_blocks | epoch, height, each u64 big-endian, then block id (32): 48 bytes | borsh 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 meta | Value | Written |
|---|---|---|
latest_height | u64 big-endian | with every tree write |
genesis | borsh Genesis | commit_genesis |
pruned_before | u64 big-endian; absent means 0 | prune, finish_restore |
blocks_from | u64 big-endian; absent means 1, and reads never give less than 1 | prune_history, set_blocks_from, finish_restore |
node/consensus_tip | borsh ConsensusTip | with every block |
node/consensus_finality | borsh (u64, FinalityProof): the newest height with a proof, and that proof | with a block that carries a proof |
node/consensus_epoch | borsh EpochInfo | with a block that changes the validators; absent in epoch 0 |
node/consensus_epochs | borsh Vec<EpochInfo>, every epoch after the first | same |
node/consensus_safety | borsh SafetyRecord { epoch: u64, state: SafetyState } | synced before a vote leaves |
node/finality/ then height as u64 big-endian | borsh FinalityProof | see 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.
versionmust be the next one: 0 on an empty database, otherwiselatest_height + 1. Anything else fails withcannot prepare version {version}, next is {expected}.JellyfishMerkleTree::put_value_sethashes 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>)],
}
- Check
block.height == prepared.version, or fail withblock height does not match prepared state. - Check
block.state_root == prepared.root, or fail withblock state root does not match prepared state. - Check the head is
height - 1, or fail withblock {height} is not the next block. - Build one
WriteBatch:- every new tree node into
jmt_nodes; - every stale node into
jmt_stale_nodes; - every new value into
jmt_values, and for a key that had a value before, an entry injmt_stale_values; latest_height;- the block bytes into
blocks; - a
TxLocationfor each transaction intotx_index; - the receipts into
receipts; - the
metaentries, undernode/.
- every new tree node into
- 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.
prepare(block.height, &executed.writes).- Compare the prepared root with
block.payload.state_root, or fail withstate root of block {height} does not match execution. - Compare
receipt_root(&executed.receipts)with the block's, or fail withreceipts of block {height} do not match its receipt root. - Build the
metaentries: alwaysconsensus_tip;consensus_finalitywhen this block has a proof;finality/<height>when it also keeps one; and for a block that changes the validators,consensus_epochandconsensus_epochs. commit_block.- 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.
- Log
committed block. - 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:
layers(parent)walks fromparentback to the last committed block and collects eachSpeculative'swrites, oldest first. An unknown parent fails.Overlay::new(store, layers)gives the executor a view of that chain.Executor::prewarmchecks signatures on all cores, then transactions apply one at a time.Store::speculative_root(&chain)takes the layers plus this block's writes. It callsput_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
| Rule | Value | Where |
|---|---|---|
| Timestamp above the parent's | a proposer uses max(now, parent + 1) | ChainApp::propose, verify |
| Timestamp not ahead of the verifier's clock | at most MAX_CLOCK_DRIFT_MS, 10,000 ms | ChainApp::verify |
| Transactions per block | at most params.max_block_transactions | genesis |
| Bytes of transactions per block | MAX_BLOCK_BYTES, 8 MiB | limits |
| Sum of gas limits per block | MAX_BLOCK_GAS, 10,000,000 | limits |
| Transaction size | MAX_TX_BYTES, 288 KiB | limits |
| After a block that changes the validators, until it is final | no transactions | ChainApp::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:
- No leaves: the root is
Hash::ZERO, 32 zero bytes. - While more than one hash is left, take them in pairs, left to right, and replace each pair with
digest(node_context, left ‖ right). - An odd hash at the end moves up unchanged. It is not paired with itself.
| Root | Leaf | Leaf context | Node context |
|---|---|---|---|
tx_root | digest(leaf_context, borsh(SignedTransaction)) | qubits.sh 2026-11 tx root leaf | qubits.sh 2026-11 tx root node |
receipt_root | digest(leaf_context, borsh(Receipt)) | qubits.sh 2026-11 receipt root leaf | qubits.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: ....
| Check | Error |
|---|---|
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 validators | genesis needs 1 to 50 validators |
| no duplicate keys | duplicate validator key |
| every key is ML-DSA-65 | validators must use ML-DSA-65 keys |
max_block_transactions above 0 | max_block_transactions must be positive |
| no duplicate address | duplicate genesis account {address} |
balances sum to at most u128::MAX | total 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:
- For each genesis account,
account/<address>withAccount::default()and the balance. account/<governance_address>getsauthset to the council of the validators, an M-of-N policy with M = n minus f. Its balance, if listed, stays.validatorswithValidatorSet { 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):
- Walk
pathfromtip. Each header'sparentmust be the id of the one before. - Require at least one header whose round is exactly one above the previous round. The tip's own round counts as the first.
- The last header must be what
qccertifies, inblockand inround. qcmust verify againstvalidatorsandchain: at least a quorum of signatures, signer indexes strictly rising. An emptypathnever 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,
}
| Record | Key | Written for |
|---|---|---|
| Tip record | consensus_tip | every committed block, overwritten |
| Tip proof | consensus_finality | any block committed with a proof, overwritten; consensus gives one to the last block of every committed run |
| Kept proof | finality/<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.
| Validators | Quorum | QC bytes | Proof bytes with one header |
|---|---|---|---|
| 1 | 1 | 3,361 | 3,485 (measured) |
| 4 | 3 | 9,993 | 10,123 (measured) |
| 7 | 5 | 16,625 | 16,761 |
| 50 | 34 | 112,789 | 113,011 |
Proving any height
Node::finality_proof(height), which /v1/blocks/{h}/raw?prove=true calls when no proof is kept for h:
- Load block
height, and the tip proof fromconsensus_finality. If either is missing, there is no proof yet. - Walk
hfromheightup to the tip height. Ifhhas a kept proof, or is the tip, stop there. - Otherwise add block
h + 1's header topath. - 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_setreturns astale_node_index_batch: each node replaced at this version, withstale_since_versionand itsNodeKey.write_treeadds one entry per node tojmt_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_treeadds(v, hash, previous)tojmt_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):
- Clamp
keep_fromto the latest height. If it is not abovepruned_before, return 0. - Scan
jmt_stale_nodesfrom the start. For every entry withstale_since_version <= keep_from, delete the node and the entry. Stop at the first entry above. - Do the same for
jmt_stale_values, deleting the value at the replaced version. - Set
pruned_before = keep_from. - 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:
- Compute
k = H("account/" ‖ address). - If
accountis present, the proof must have a leaf withkey_hash = kandvalue_hash = H(account bytes). - If
accountis null and the proof has a leaf, that leaf's key hash must differ fromk, and must share at leastsiblings.len()leading bits with it. That leaf then occupies the only placekcould be. - If
accountis null and the proof has no leaf, the position is empty. - Start from the leaf hash, or the placeholder if there is no leaf.
- Number the bits of
kfrom 0, the top bit of the first byte. Withnsiblings, siblingi, counted from the bottom, uses bitn - 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). curmust equalstate_root.- Anchor the root. Fetch
/v1/blocks/{H}/raw?prove=true, decode the block and proof, and checkblock.id() == block_hash,FinalityProof::verify(block.round, validators, chain)andblock.payload.state_root == state_root. The validators andchainare 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.
| Case | Status | Error |
|---|---|---|
H above the head | 404 | that height is not committed yet |
No block held at H | 404 | no block at that height |
| Block held, state pruned | 404 | state at that height was pruned |
| Address does not parse | 400 | the parse error |
The full API is on the API page.
Source
chain/crates/qubits-state/src/lib.rschain/crates/qubits-state/src/store.rschain/crates/qubits-state/tests/store.rschain/crates/qubits-types/src/block.rschain/crates/qubits-types/src/contract.rschain/crates/qubits-types/src/genesis.rschain/crates/qubits-types/src/hash.rschain/crates/qubits-types/src/rpc.rschain/crates/qubits-types/src/lib.rschain/crates/qubits-consensus/src/types.rschain/crates/qubits-execution/src/lib.rschain/crates/qubits-node/src/app.rschain/crates/qubits-node/src/lib.rschain/crates/qubits-node/src/rpc.rschain/crates/qubits-node/tests/sync.rs