Skip to content
minanodeBeta release

Light client endpoints

A light client checks the chain against a node it does not trust. The node serves the best tip's header, and an account with its Merkle path to a ledger hash in a block the client verifies.

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:3000

GET /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, staking or next. 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_unavailable with a hint: ask again with block set to root.state_hash from /light/best-tip. The hash is at blockchain_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"} 503

Limits

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 block stateHash. Use the root's state hash. OCaml has no cap on accountInfos; 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.

  1. Decode header. Verify its proof against devnet's blockchain verification key, and check that its genesis state hash is the one you pin.
  2. For an answer about the root, link the root to the tip. Start from the hash of root.protocol_state and fold each body hash in order as state_hash = H(previous state_hash, body_hash). The last result must be the tip's state hash.
  3. Hash the decoded account: the result must be leaf_hash. Fold merkle_path from the leaf with Mina's Merkle hash for each height. The result must be ledger_hash.
  4. Check that block.state_hash is the tip or root you verified, and that ledger_hash is the value at ledger_hash_field in 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 $pk

It 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.