Lazy Contract State Query RPC
| MIP | 0009 |
|---|---|
| Status | Proposed |
| Category | Standards |
| Authors | Rodrigo Quelhas @RomarQ |
| Created | 2026-06-03 |
Abstract
This MIP defines midnight_queryContractState, a JSON-RPC method for reading specific fields of a deployed contract’s state via paths through its StateValue tree. It exposes the lazy navigation primitive midnight-ledger already supports, so clients can read single leaves instead of fetching the full serialized state on every call.
Motivation
A Compact contract’s state is stored in midnight-ledger as a content-addressed DAG of StateValue nodes: Array, Map, Cell, and BoundedMerkleTree variants connected by Sp<...> pointers. Sub-trees can be loaded from ParityDB independently of the others, and this is the same structure the on-chain VM walks every time a circuit executes an idx instruction.
Today’s midnight_contractState RPC flattens this structure on every read: it loads the entire DAG, tagged-serializes it, and returns the full blob. dApps typically want a subset of fields at a time, not the whole tree. For contracts with non-trivial state this is also a denial-of-service surface: each read forces the node to serialize and transport the entire state.
Measurements from midnightntwrk/midnight-node#1069 (the originating feature request, devnet, Map<Field, Bytes<32>> contract):
| Map entries | RPC time | Response size |
|---|---|---|
| 0 | 5 ms | 2.9 KB |
| 5,000 | 145 ms | 373 KB |
| 10,000 | 672 ms | 905 KB |
| 17,000 | 2,505 ms | 1,641 KB |
Specification
Method
| Name | midnight_queryContractState |
| Params | positional, see below |
| Result | array of RpcStateQueryResult |
Parameters:
contract_address: string. Lowercase hex of a 32-byteContractAddress, without0xprefix.queries: array ofRpcStateQueryobjects. The array MUST contain at mostMAX_STATE_QUERIESentries (see Limits).at: optional block hash (0x-prefixed hex). When absent ornull, the server uses the best block.
RpcStateQuery
{ "path": ["0x...", "0x...", "..."] }
path is an ordered list of PathKeys, each a wire encoding of a Midnight AlignedValue.
An AlignedValue is a pair (Value, Alignment): the Value is a sequence of byte-vector atoms, and the Alignment describes how to interpret them as a typed value. The common alignments are:
| Rust / Compact type | Alignment |
|---|---|
bool / Boolean |
Bytes(1) |
u8 / Uint<8> |
Bytes(1) |
u16 / Uint<16> |
Bytes(2) |
u32 / Uint<32> |
Bytes(4) |
u64 / Uint<64> / Counter |
Bytes(8) |
u128 / Uint<128> |
Bytes(16) |
[u8; N] / Bytes<N> |
Bytes(N) |
Fr |
Field |
| tuples / structs | concatenated alignments |
Both halves are part of the encoded form, so a PathKey constructed from u8(0) is not byte-equal to one constructed from u64(0). For Map keys this distinction is significant: the server’s lookup compares the deserialized AlignedValue (alignment included) against the stored key.
For Array and BoundedMerkleTree indices the alignment is collapsed to a numeric position, but the widths differ:
Arraypositions are decoded as au8, matching the VM’sidxinstruction. AnArrayis capped at 16 entries by ledger invariant (onchain-state/src/state.rs), so 8 bits suffice. Integer keys of any width whose numeric value fits inu8are accepted; a value that does not fit (e.g.u16(300)) yields a “key did not decode as au8” per-query error distinct from “index out of bounds.”BoundedMerkleTreepositions are decoded as au64against1 << tree.height(). Out-of-range positions yield a per-query error.
The wire form of a PathKey is:
"0x" + hex(Serializable::serialize(av)), lowercase hex, even length, with the leading0xprefix REQUIRED.Serializable::serializeis the untagged binary form defined inmidnight-serialize(serialize/src/serializable.rs). The companiontagged_serializeprepends amidnight:<tag>:prefix (e.g.midnight:fab-aligned-value[v1]:); this method intentionally omits it because the type is fixed by the method name (see Why untagged wire format?).- The hex-decoded byte length of each
PathKeyMUST NOT exceedMAX_KEY_BYTES(see Limits). - A
patharray MUST NOT be empty and MUST contain at mostMAX_PATH_DEPTHentries (see Limits).
RpcStateQueryResult
{
"query": { "path": [...] },
"value": "0x...", // present on success
"error": "string" // present on per-query failure
}
Exactly one of value and error MUST be set on the wire. value, when present, is "0x" + hex(Serializable::serialize(resolved_state_value)). query echoes the input query verbatim so callers can correlate responses without external state.
Navigation semantics
The server starts at the contract’s StateValue root (call it current). For each PathKey in the path, in order:
- Let
keybe the deserializedAlignedValuefor this step. - Branch on
current’s variant:Array(arr):keyMUST decode as an integer fitting inu8(matching the VM’sidxinstruction). Two distinct per-query errors are possible:- “key did not decode as a
u8”: the alignment is non-integer, or the numeric value exceedsu8::MAX(e.g.u16(300)). - “index out of bounds”: the decoded
isatisfiesi >= arr.len().
Otherwise set
current = arr[i].- “key did not decode as a
Map(map): ifmapcontainskey, setcurrent = map[key]. Ifmapdoes not containkey, setcurrent = StateValue::Nulland continue with the remaining steps. This mirrors the VMidxinstruction’s behaviour: a map miss substitutesNulland the path continues. BecauseNullis not an indexable variant, any subsequent step necessarily yields a “step cannot be indexed” per-query error, which is the right signal to the caller.BoundedMerkleTree(tree):keyMUST decode as au64positionp. Computemax = 1u64 << tree.height()(the0 < height <= 32ledger invariant guarantees this fits inu64; implementations SHOULD usechecked_shldefensively in case of malformed state). Ifp >= max, yield a per-query error. Otherwise look up the leaf atp: if present, setcurrentto aCellcontaining the leaf hash; if the slot is empty, setcurrent = StateValue::Nulland continue (same rationale as theMapmiss case).Cell(_)orNull(the leaf variants): yield a per-query error “step cannot be indexed” — there is nothing to navigate into.
After all steps:
- If
currentisArray,Map, orBoundedMerkleTree, yield a per-query error: “path resolves to a collection; provide a deeper path.” The intent of this method is to return concrete field values; allowing a collection return would let a single query inflate the response to an unbounded size. - Otherwise return
value = "0x" + hex(Serializable::serialize(current)).
Example: reading a counter Cell
A typical Compact contract’s state is an Array at the top level whose entries are the ledger’s fields, indexed in declaration order. Take a contract that declares three fields:
Array(3)
├── [0] Cell(AlignedValue: u8) ← version (Uint<8>)
├── [1] Map<Fr, Cell> ← balances (Map<Field, Uint<64>>)
└── [2] Cell(AlignedValue: u64) ← counter (Counter)
To read counter, the client encodes the field index as AlignedValue::from(2u8) and submits:
{
"method": "midnight_queryContractState",
"params": [
"<contract_address_hex>",
[
{ "path": ["0x<untagged hex of AlignedValue::from(2u8)>"] }
]
]
}
Server-side traversal:
| step | current before |
key | current after |
|---|---|---|---|
| 1 | Array(3) [...] |
u8(2) |
Cell(AlignedValue: u64) |
| end | Cell (leaf) |
return Serializable::serialize(Cell) |
Response:
{
"result": [
{
"query": { "path": [...] },
"value": "0x<untagged hex of Cell(AlignedValue: u64)>"
}
]
}
The client deserializes value back into a StateValue via the untagged Deserializable::deserialize routine matching the server’s Serializable::serialize.
Reading an entry from balances is a two-step path [u8(1), Fr(<map_key>)]: the first step navigates the top-level Array to the Map at field index 1, the second looks up the requested key inside the Map.
Limits
Implementations MUST enforce each of the protective caps below. The specific values are suggested defaults and may be tightened or loosened by an implementation, but each cap MUST exist.
| Limit | Default | Scope |
|---|---|---|
MAX_STATE_QUERIES |
100 | per request |
MAX_PATH_DEPTH |
16 | per query |
MAX_KEY_BYTES |
512 | per path key |
The defaults are not arbitrary, but only MAX_KEY_BYTES is grounded in a structural ledger bound:
MAX_PATH_DEPTH = 16is a conservative policy cap, not a structural one: the ledger imposes no maximum nesting depth, so a contract could in principle buildMap<_, Map<_, ...>>chains beyond 16 levels. The default is set well above any realistic Compact field nesting (typically two to three levels: top-levelArrayof fields withMap/Arrayinside).MAX_KEY_BYTES = 512accommodates typical Compact key widths (e.g.HashOutputorFrat 32 bytes plus alignment overhead, common tuple keys) with comfortable headroom.
Aggregate response size is bounded by the JSON-RPC server’s own maximum response size, which jsonrpsee and substrate’s RPC layer already enforce; the MIP does not redefine it.
Error model
Top-level JSON-RPC errors (the whole batch fails):
| Cause | Error |
|---|---|
contract_address not valid lowercase hex of 32 bytes |
BadContractAddress |
| Well-formed address with no contract deployed | ContractNotPresent |
queries.len() > MAX_STATE_QUERIES |
TooManyQueries |
Malformed PathKey (bad hex, missing 0x, oversize, AlignedValue decode failure) |
JSON-RPC deserialization error |
| Internal node failure (runtime API unavailable, ledger storage unreachable) | UnableToGetContractState |
Unknown or pruned at block hash |
UnableToGetContractState (matches the existing midnight_contractState behavior; a distinct error variant is a possible follow-up) |
Per-query failures (the conditions defined in Navigation semantics) are reported in RpcStateQueryResult.error as a human-readable string. The exact wording is non-normative.
Rationale
Why untagged wire format?
midnight-serialize supports both tagged and untagged binary forms. The tagged form (tagged_serialize) prepends midnight:<tag>:, a single token whose <tag> carries the type name and a version discriminator: AlignedValue is currently fab-aligned-value[v1], so its tagged prefix is midnight:fab-aligned-value[v1]:. StateValue is impact-state-value[v2]. The untagged form (Serializable::serialize) omits the prefix entirely.
This method uses the untagged form for two reasons:
- Compactness. The tag is constant per path key and per result value, so transmitting it on every entry of every batch is dead weight. For a maximum batch (
MAX_STATE_QUERIES × MAX_PATH_DEPTHkeys), dropping the prefix saves on the order of tens of kilobytes. - The type is fixed by the method name. A
midnight_queryContractStatepath key is always anAlignedValue; a result value is always aCellorNull. There is no parser-side ambiguity for the tag to resolve.
The trade-off is that the wire bytes carry no [vN] discriminator. Clients are expected to know the on-chain ledger version they are talking to (discoverable via midnight_apiVersions and get_ledger_version). An incompatible AlignedValue layout change would be introduced under a new method name or a new top-level API version, not silently within this method.
Path to Active
Acceptance Criteria
The MIP transitions to Active once the following are met:
- The
midnight_queryContractStateRPC implementation is merged intomidnight-node; - The method is registered in the node’s OpenRPC document for the corresponding release, so SDKs and tooling can discover and call it.
Implementation Plan
midnight-node: ship the reference implementation of the JSON-RPC method, register it in the node’s OpenRPC document, and add end-to-end coverage.
Backwards Compatibility Assessment
This MIP adds a new JSON-RPC method and does not modify any existing one. No on-chain, transaction-format, or consensus change.
Security Considerations
This RPC is a strict reduction of the over-fetch surface introduced by midnight_contractState: instead of returning the full serialized contract state on every call, it returns only the leaves the caller asks for.
Work per request is bounded to O(MAX_STATE_QUERIES × MAX_PATH_DEPTH) storage lookups. The per-leaf response size is bounded by an existing ledger invariant: CELL_BOUND = 32 KiB (onchain-state/src/state.rs) is the maximum serialized size of any Cell, and Navigation rejects intermediate collections, so every successful result is a Cell or Null. The aggregate response size (MAX_STATE_QUERIES × CELL_BOUND = 3.125 MiB worst case) is bounded by the JSON-RPC server’s own maximum response size, which jsonrpsee and substrate’s RPC layer already enforce.
Implementation
The reference implementation in midnightntwrk/midnight-node consists of:
- A JSON-RPC method on the
MidnightApitrait (pallets/midnight/rpc/src/lib.rs). - A wrapped wire type
PathKey(AlignedValue)with serde impls that round-trip through untagged hex. - A bridge function that walks the contract’s
StateValuetree lazily against ParityDB (ledger/src/versions/common/mod.rs). - OpenRPC entries and JSON schemas (
node/src/openrpc.rs, regenerateddocs/openrpc.json).
Testing
The reference implementation’s end-to-end tests cover:
- Successful path navigation to a
Cellleaf, with the result deserialized client-side and equality-checked against the expectedStateValue. - A batched call that exercises all three result kinds in one round-trip: a
Cellvalue, aMaphit returningNull, and an array out-of-bounds (per-query error). - A top-level error path for a well-formed but never-deployed contract address (
ContractNotPresent).
Additional reference tests verify the OpenRPC document is in sync with the runtime-registered method set and that the static OpenRPC JSON does not drift from build_openrpc_document.
Acknowledgements
To be filled in when reviewers/co-authors are confirmed.
Copyright Waiver
All contributions (code and text) submitted in this MIP must be licensed under the Apache License, Version 2.0. Submission requires agreement to the Midnight Foundation Contributor License Agreement, which includes the assignment of copyright for your contributions to the Foundation.