← Research blog

RESEARCH / INTERFACES

Following a Historical Query to the Final Read

We trace the requested height from CometBFT through Cosmos SDK before checking which stored version supplies the answer.

Map the query before inspecting a height check

A historical query crosses at least two programs and more than one routing decision. In CometBFT v1.0.0, ABCIQuery packages Path, Data, Height, and Prove into a request to the application. In Cosmos SDK v0.53.0, BaseApp.Query handles the request and dispatches it according to the path. Those source files establish transport and routing; neither one alone identifies the state version that supplied the returned bytes.

RECONSTRUCTED SOURCE MAPHistorical ABCI query routes
CometBFT + Cosmos SDK v1.0.0 / v0.53.0

Scroll the diagram horizontally

ABCIQuery passes through CometBFT ProxyAppQuery and Cosmos SDK BaseApp.Query. BaseApp routes a module query through CreateQueryContext and a versioned multistore or a store query through a queryable store and named substore.
PINNED SOURCE

The branch is operationally significant. On the gRPC route, CreateQueryContext selects a context for the requested height before the module handler runs. The store route sends a store request to queryable.Query, and the root multistore forwards it to a named substore. A source trace that stops at BaseApp.Query would wrongly collapse these two paths into one.

The resulting question is precise: for a given route, which final read used the requested version? The response’s height field and a correctly forwarded parameter are useful trace points, but neither substitutes for that read. This is why the two-value, two-height control below matters.

One hop in source

CometBFT v1.0.0 accepts a height in its ABCIQuery RPC handler and passes it into the application query request. The assignment is visible in rpc/core/abci.go, lines 15–27:

resQuery, err := env.ProxyAppQuery.Query(context.TODO(), &abci.QueryRequest{
    Path: path,
    Data: data,
    Height: height,
    Prove: prove,
})

This is evidence for one hop, from RPC to QueryRequest. It says nothing by itself about how an application handles a historical query.

Where semantics are decided

A parameter has two lives: it can be transported through an interface, and it can be honoured at the final read. Code review often stops at the first visible assignment. For historical queries, the interesting boundary is where the application chooses a state snapshot or reports that the requested snapshot is unavailable.

The same distinction applies to replay options, protocol versions, and feature flags. Merely seeing an argument forwarded is neither proof of correct semantics nor evidence of a defect. The answer depends on the receiver and on the caller’s documented expectation.

Differential height control

Create two heights with deliberately different values for one key. Query the earlier height and record the returned value, response height, and any proof; compare them with both snapshots. Then follow QueryRequest.Height through the application to the final store read. Identical values at both heights make this test inconclusive. No backend behavior is claimed from the quoted CometBFT handler alone.

A second public source hop

Cosmos SDK v0.53.0 gives a useful application-side example. BaseApp.Query treats request height zero specially, replacing it with LastBlockHeight(), then dispatches by query path. The file routes gRPC queries and /store queries through different handlers. The exact route is therefore part of the evidence; a trace through one handler does not automatically describe another.

The SDK’s CreateQueryContextWithCheckHeader rejects negative or future heights and selects a store version. This line is the important boundary:

cacheMS, err := qms.CacheMultiStoreWithVersion(height)

If the version cannot be loaded, the function returns an error saying it failed to load state at that height. The context then receives WithBlockHeight(height) (lines 1281–1286). That source is stronger than merely seeing the height echoed in a response: it shows a versioned store selection. It does not prove that every downstream module handler reads exclusively from that context. That final read must be followed for the specific route under review.

The route distinction is concrete in the same versioned source. The gRPC handler calls CreateQueryContext(req.Height, req.Prove) and passes the context to its module handler. The /store handler instead converts the request to a store query and calls queryable.Query(&sdkReq). In rootmulti.Store.Query, the multistore parses the substore name, strips its path prefix, and forwards the request to that substore. It may also append a proof operation. The gRPC context-selection line is therefore not proof for a /store query; the substore’s own Query implementation is the next necessary source hop.

The CometBFT and Cosmos SDK examples come from independently pinned sources. For an actual service, capture its versions and configuration first.

A discriminating test record

Committed height H:   key = "before"
Committed height H+1: key = "after"
Query H:              should return "before" for a historical read
Query H+1:            should return "after"
Query unavailable H-1: explicit error is distinguishable from latest-state fallback

For this proposed local fixture, record the request height, result value, response height, proof flag and proof outcome, and the final store-read call. Using two equal values would make an ignored parameter look correct. A response label by itself is also insufficient: it can name a requested height while the data came from elsewhere. The same differential design works for protocol versions and feature flags whenever two inputs can be made to produce observably different legitimate results.

A response height is a weak witness

The /store branch assigns resp.Height = req.Height after queryable.Query returns. That assignment is not the same operation as selecting historical data. For this route, the substore query and its use of sdkReq.Height need inspection. A response can report the caller’s requested height even if some downstream value was supplied from another source; the label alone is not a data provenance proof.

For a proof-bearing query, preserve the entire response rather than only the value. The root multistore path checks for a missing proof when one is required, loads commit information for the result height, and appends a proof operation. Those checks offer additional evidence about which version the proof is tied to, but the client still has to verify it against the intended trusted commitment. A test that simply sees nonempty proof bytes has not completed that verification.

A useful failure matrix therefore has four rows: exact historical height with different values, latest-height sentinel 0, a future height, and a pruned or unavailable height. Preserve the error code and route for each. The expected outcomes may differ across /store and module gRPC handlers because their call graphs differ. That difference is why a single successful historical query cannot stand in for the whole interface.

MORE RESEARCH

Explore more research.

Research blog