Bug: GET /api/v1/epochs/{N}/participants returns 500 for past epochs (CreatedAtBlockHeight=0) #983
Bug
GET /api/v1/epochs/{N}/participants returns 500 Internal Server Error for past epochs. Current epoch works fine.
Repro
GET http://node1.gonka.ai:8000/api/v1/epochs/215/participants
→ 500 Internal Server Error: height must be greater than 0, but got 0
Epoch 215 consistently reproduces this. Any past epoch where CreatedAtBlockHeight was not yet populated will fail.
Root Cause
In queryActiveParticipants (get_participants_handler.go):
- First query (no height) fetches
activeParticipants blockHeight := activeParticipants.CreatedAtBlockHeight— for old epochs this is 0 (field was not populated at storage time)- Second call
QueryByKeyWithOptions(..., height=0, prove=true)— CometBFT rejectsheight=0with the above error
Fix
Check if blockHeight == 0 before the second query. If so, skip the proof query and return the first result directly, with a Warn log for observability.
Fix is implemented in PR #973.
💬 Comments (3)
Correction to my comment above: I wrote that the 500 is "live". That was not supported — the repro forces CreatedAtBlockHeight = 0 through a stub, which shows the code path fails if reached, not that it is reachable.
Checking the public nodes: current epoch is 348 (height 5,377,294), and every past epoch I probed — 1, 5, 50, 100, 200, 215, 300, 330, 340, 344, 346, 347 — returns 404 active participants not found for epoch on both node1 and node2. Only the current epoch returns 200, and its record has created_at_block_height populated (5367703). So on those nodes the zero-height path cannot be exercised at all.
I could not determine why past epochs are unretrievable. Nothing in the module deletes the ActiveParticipants blob (only the ActiveParticipantsSet collection is cleared, and only per-epoch on write), no upgrade handler removes it, and the key format matches what the live proof shows — ActiveParticipants/value/ + big-endian epoch + /, which decodes correctly out of epoch 348's proof_ops. By code reading the blob for epoch 347 should be in state and readable. It isn't. So I can't rule out that an archive node serving historical records would still hit this path.
Net:
- The root-cause correction stands — the error comes from
GetValidatorSetByHeight, not the proof-bearing ABCI query. - The note about #973 stands — it patches a call that isn't the failing one, and the function it targets no longer exists on
main. - Nobody should spend time on a fix until reachability is settled. Withdrawing my offer to open a PR for now.
The larger question this turned up is probably worth more attention than the original report: /v1/epochs/{N}/participants appears to serve only the current epoch, which would make historical participant data — and the proofs over it — unretrievable through this endpoint. If that is intended, this issue can just be closed. If it is not, that is the thing to look at.
Following up on the investigation above: I've opened #1556 fixing the code-level fatal path that was confirmed here — getEpochParticipants no longer sends CreatedAtBlockHeight == 0 to GetValidatorSetByHeight (which CometBFT rejects), and instead degrades to an empty validators array, mirroring the function's existing non-fatal GetBlockByHeight handling.
Deliberately not marked as fixing this issue: the question raised above — why live public nodes return 404 for all past epochs while no delete path for the ActiveParticipants blob exists in the code — remains open and looks operational (pruning/statesync config) rather than code-level. That still deserves its own investigation.
🔄 Auto-synced from Issue #983 every hour.
This is still reproducible on current
main(4fa6be0, Upgrade v0.2.15 #1497) — but the root cause in the description is not the one that fires, and #973 patches a call site that no longer exists.TL;DR - The 500 is real and live. - It does not come from the proof-bearing ABCI query.
height=0 + prove=trueis accepted. - It comes fromGetValidatorSetByHeight(Height: CreatedAtBlockHeight), which is fatal. - The handler moved fromdecentralized-api/internal/server/public/get_participants_handler.gotocommon/queryapi/epoch.go, so #973 no longer applies to anything.Reproduction
Drop into
common/queryapi/tests/. The stub mirrors the real backend's height contract; each behaviour is sourced in the comment.Result (
go test ./queryapi/tests/ -run TestIssue983 -v, Go 1.25.9):Same error string as the report, reached from a different call than the description claims. Note the proof query in the same run returned normally —
height=0 + prove=trueis not what breaks.Where it actually breaks
common/queryapi/epoch.go:Height: CreatedAtBlockHeight. With 0 the SDK resolves it to latest, so no error. (Worth noting separately: the proof is then anchored to the latest app hash while verification at L174–186 compares against blockCreatedAtBlockHeight+1= 1. Verification fails, but it is log-only, so the endpoint would still answer 200 with a proof that verifies against nothing.)GetBlockByHeight(CreatedAtBlockHeight + 1)= height 1, valid, and non-fatal anyway. Fine.GetValidatorSetByHeight(Height: CreatedAtBlockHeight)= 0 → error →return nil, err→ 500. This is the one.The old handler had two fatal zero-height calls (
BlockandValidators). The move tocommon/queryapifixed the first and kept the second.Why no test catches it
common/queryapi/tests/epoch_participants_golden_test.gohardcodesCreatedAtBlockHeight: 100(L113), and itsGetValidatorSetByHeightstub discards the request entirely (L148,_ *cmtservice.GetValidatorSetByHeightRequest), so the height never reaches an assertion.On #973
It guards the ABCI proof query, which is not the failing call, so it would not have removed the 500 even when it was written. Separately, the function it patches no longer exists on
main—get_participants_handler.gois down to 84 lines and holds onlygetParticipantByAddress/getAccountByAddress. It needs redoing againstcommon/queryapi/epoch.gorather than rebasing.Suggested fix
Guard
CreatedAtBlockHeight == 0before the validator-set call incommon/queryapi/epoch.go. The design question worth settling first: this endpoint returnsActiveParticipantWithProof, so degrading to a 200 withoutproof_opsis fail-open on a verification endpoint — a client that does not nil-check would treat unverified data as verified. A 400/404 for epochs that predate the field may be the safer contract. Happy to open a PR either way once the direction is agreed.