Light client endpoints
Where they are served
The endpoints are on the node's HTTP port, with the dashboard and API, and need no flag. They follow --http-addr, which is 127.0.0.1 by default. Before you open the port to other machines, read viewing it on a server.
A node started with --public-graphql answers 404 for /light/*, and its public GraphQL refuses the path fields below.
The examples use curl and jq, and these two shell variables: a devnet account and the node's address.
pk=B62qqLUmFPKkRhNUXY4N8yoyoDTWmkYz585AFYNK85cjfGyjYBLNvmz
node=http://127.0.0.1:3000GET /light/best-tip
The best tip as a light client verifies it: the tip's header (its protocol state and proof), the root's protocol state, and the state body hashes that link the root to the tip. It carries no block bodies. The root is the oldest block the node keeps, 290 blocks behind the tip.
format=json is the default. format=binprot returns the three values back to back, in OCaml's types Mina_block.Header.t, Mina_state.Protocol_state.Value.t and State_body_hash.t list, as application/octet-stream. Both forms set the x-mina-state-hash header to the tip's state hash.
The answer is 23,546 bytes in binprot and 32,344 bytes in JSON.
- state_hash, height
- The tip.
- genesis_state_hash
- The genesis state hash the tip carries. Compare it with the one you pin.
- global_slot_since_genesis
- The tip's slot.
- ledger_hashes
- The tip's staged, snarked, staking and next ledger hashes.
- header
- Base64 of the tip header's binprot: protocol state, proof and protocol versions.
- root
- The root's state_hash, height and protocol_state (base64 binprot).
- body_hashes, body_hash_count
- Base64 of the binprot list of state body hashes, from the root's child to the tip.
- trust
- What a client may rely on, as a sentence.
Every field but header, root.protocol_state and body_hashes is a convenience copy. Take the values from those three after you verify them.
curl -s $node/light/best-tip \
| jq '{state_hash, height, ledger_hashes, root: .root.state_hash}'curl -s -D - -o best-tip.bin "$node/light/best-tip?format=binprot"GET /light/account/{public_key}
One account in a named ledger of a best-chain block, with its index, leaf hash and Merkle path. The answer names the ledger hash the path folds to, where that hash sits in the block's protocol state, and the block.
- ledger
- Required:
staged,snarked,stakingornext. See which ledgers a node holds. - token
- A token id. MINA if absent.
- block
- A best-chain block's state hash. The best tip if absent.
The answer has these fields:
- ledger_hash
- The root the Merkle path folds to.
- ledger_hash_field
- Where ledger_hash sits in the block's protocol state.
- block
- The state_hash, height and global_slot_since_genesis of the block whose ledger was read.
- public_key, token_id, index
- The account and its leaf position, left to right.
- leaf_hash
- The account's hash, as a decimal field element.
- merkle_path
- 35 elements, leaf first. left set means the node is a left child and left holds its sibling's hash; right means the reverse.
- account
- Base64 of the account's binprot. Hash it to get leaf_hash.
- summary
- Balance in nanomina, nonce, delegate and whether it has a zkApp: convenience copies.
- trust
- What a client may rely on for this ledger.
An answer for an account without a zkApp is about 4.8 KB.
curl -s "$node/light/account/$pk?ledger=staged" \
| jq '{ledger_hash, ledger_hash_field, block, index, leaf_hash, summary}'root=$(curl -s $node/light/best-tip | jq -r .root.state_hash)
curl -s "$node/light/account/$pk?ledger=snarked&block=$root" \
| jq '{ledger_hash, ledger_hash_field, block, index}'Which ledgers a node holds
- staged
- Every best-chain block. The hash is at
blockchain_state.staged_ledger_hash.non_snark.ledger_hash. The block producer commits to it, and the block proof does not check it. - snarked
- The root only. At the tip the answer is
ledger_unavailablewith ahint: ask again withblockset toroot.state_hashfrom/light/best-tip. The hash is atblockchain_state.ledger_proof_statement.target.first_pass_ledger. - staking
- The tip's staking epoch ledger. The hash is at
consensus_state.staking_epoch_data.ledger.hash. - next
- The tip's next epoch ledger, once the root is the last block before the tip's epoch. The hash is at
consensus_state.next_epoch_data.ledger.hash.
The next ledger is unavailable for about 290 blocks after each epoch boundary. At its first block of an epoch, the tip takes the snarked ledger of the previous epoch's last block as its next ledger. The node holds that ledger once that block is the root, 290 blocks later. Until then, ledger=next answers ledger_unavailable.
On 26 October 2026, the 290 blocks between root and tip spanned 580 slots of 90 seconds, so next was unavailable for about 14.5 hours after the epoch began. If every slot had a block, the gap would be about 7.25 hours.
Every new block moves the root. Take the root from a fresh /light/best-tip: a root one block old may already answer block_not_in_best_chain.
Errors
An error is JSON with a readable error and a stable code. Match on code.
- bad_request
- 400. A bad key, token, block hash, ledger or format, or no ledger. The request does not reach the node.
- not_synced
- 503. The node has no best tip, or is bootstrapping. Catch-up of new blocks does not count.
- block_not_in_best_chain
- 404. The block given is not in the best chain, for example because the root moved past it.
- account_not_found
- 404. No such account in that ledger. A Merkle path cannot prove absence, so this is the node's word only.
- ledger_unavailable
- 503. The node does not hold that ledger. For snarked, a hint names the ledger it holds.
- busy
- 503. Too many light requests or accounts are pending. Retry later.
- timeout
- 503. The node did not answer in time.
- internal
- 500. For example, a path that does not fold to its hash. The node checks each path before it answers.
curl -s -w ' %{http_code}\n' "$node/light/account/$pk?ledger=next"{"code":"ledger_unavailable","error":"the node does not hold the next ledger jwqvkVGCsqoE3HFRux78jJTM1dTZfZntYK6yfobQAQSXCLjh8VQ"} 503Limits
- Requests in flight
- 16, for /light/* and the GraphQL path fields together. More are refused at once with busy: there is no queue.
- Accounts pending
- 32, over all pending light requests. A request that would take more is refused with busy.
- Accounts per membership query
- 32. More get the GraphQL error at most 32 accountInfos per request.
- Wait for the node
- 10 seconds, then timeout.
The best tip is encoded once per tip and root. Concurrent requests wait for that encoding instead of starting their own.
GraphQL
Two GraphQL fields give the same paths, in OCaml's names and shapes, at /graphql on the HTTP port. Both are under the same limits as /light/*. When those are full, they answer the GraphQL error too many light requests in flight.
account(publicKey, token) { index leafHash merklePath }: the path in the best tip's staged ledger. All three come from one read of one ledger snapshot. They are null while the node bootstraps.encodedSnarkedLedgerAccountMembership(accountInfos, stateHash): each account as base64 binprot, with its path in the snarked ledger of the best-chain blockstateHash. Use the root's state hash. OCaml has no cap onaccountInfos; this node takes at most 32.
jq -n --arg pk $pk '{
query: "query($pk: PublicKey!) { account(publicKey: $pk) { index leafHash merklePath { left right } } }",
variables: {pk: $pk}
}' | curl -s $node/graphql -H 'content-type: application/json' -d @- \
| jq '.data.account | {index, leafHash, path: (.merklePath | length)}'root=$(curl -s $node/light/best-tip | jq -r .root.state_hash)
jq -n --arg pk $pk --arg root $root '{
query: "query($pk: PublicKey!, $root: String!) { encodedSnarkedLedgerAccountMembership(accountInfos: [{publicKey: $pk}], stateHash: $root) { account merklePath { left right } } }",
variables: {pk: $pk, root: $root}
}' | curl -s $node/graphql -H 'content-type: application/json' -d @- \
| jq '.data.encodedSnarkedLedgerAccountMembership[0].merklePath | length'Verify what you get
Every value comes from one node, so check it before you rely on it.
- Decode
header. Verify its proof against devnet's blockchain verification key, and check that its genesis state hash is the one you pin. - For an answer about the root, link the root to the tip. Start from the hash of
root.protocol_stateand fold each body hash in order asstate_hash = H(previous state_hash, body_hash). The last result must be the tip's state hash. - Hash the decoded
account: the result must beleaf_hash. Foldmerkle_pathfrom the leaf with Mina's Merkle hash for each height. The result must beledger_hash. - Check that
block.state_hashis the tip or root you verified, and thatledger_hashis the value atledger_hash_fieldin that block's protocol state.
If any check fails, discard the answer. A node that lies cannot make a path verify: it can only refuse, or answer for another block, which the last check catches.
Two limits remain. account_not_found is not provable. One node's best tip is not proof of the best chain: ask several nodes, compare their headers with Mina's fork choice rule, and check the tip's slot against the clock.
The repository's tools/light-probe runs these checks against a node. Run it in a checkout of the repository: it reads devnet's verification key from the checkout's data/vk/.
cargo run --release -p mina-light-probe -- \
--http $node --graphql $node/graphql --account $pkIt verifies the tip, the body hashes, the account in each ledger (the snarked one at the root), and the GraphQL staged path. It reports a ledger the node does not hold as not verified.