mina-explorer-api
Drop-in, bug-compatible replacement for the Blockberry Mina REST API.
Base URL https://mina-explorer-api.minaprotocol.com
Path structure /mina-{network}/v1/... — the network is in the path; auth is the x-api-key header (unchanged from Blockberry).
Networks active: mina-mainnet, mina-devnet
Asked to use mina-mainnet, mina-devnet — Every network this deployment serves is in scope as of v1.0 — mainnet included. This remains guidance rather than a restriction the service enforces: keys are not scoped to a network, so any active network will answer a valid key.
What this is
A drop-in, bug-compatible replacement for the Blockberry Mina REST API. Point your existing client at the base URL above, keep your x-api-key header, and the /mina-{network}/v1/... paths work unchanged. See the migration guide to switch over, and Get an API key to request credentials (key values are re-issued by us — Blockberry keys are not portable).
Where an endpoint diverges, it degrades honestly — documented null fields and window-bounded reads — rather than fabricate or 500. Every divergence is listed below: always-null fields on the null: line, and each place our wire differs from Blockberry's on its own Blockberry divergence line, labelled with the networks it affects.
Compatibility matrix
55 endpoints · Full 5 Partial 44 Stub 6
null fields or a coverage caveat.Stub — placeholder (empty / 404) with no backing source.| Endpoint | Status | Notes & null fields |
|---|---|---|
| Blocks | ||
/v1/blocksGET | Partial | null: accountName, accountImg, ledgerHash, parentHash, snarkJobsCount, snarkersCountEnvelope A; totalElements capped at 10 000. isCanonical is a real boolean here (contrast detail, §D). type=ORPHANED returns empty until the index. orderBy=ASC is INDEX-ONLY (#146): the index serves it exactly (ORDER BY height ASC, so page 0 really is the oldest blocks), while the window-bounded fallback — anchored at the pending tip and paging DOWN via blockHeight_lt — cannot reach the oldest block at all, so it returns an EMPTY page and counts it on mea_blocks_asc_degraded_total (with a warning log) rather than serving the reversed tip window it used to. Measured on mina-mesa 2026-08-04 (that network is decommissioned; the measurement is why the fallback behaves as it does): our first ASC item was 302188, the tip, where Blockberry's was 296372 — HTTP 200, right shape, wrong rows, and R8 forbids exactly that. orderBy=DESC is unaffected on both paths. totalCount is a DELIBERATE DIVERGENCE on the fallback and parity on the index: Blockberry reports the indexed row count (7334 in that same measurement) and so do we whenever the index serves the request, but the fallback reports the networkState tip height (302188), which equals a row count only on a chain whose archive starts at height 1 — that one's genuinely started at 296372, and any chain whose archive was seeded above genesis is the same case. It is kept rather than zeroed because it is the fallback's only deep-paging signal and a zero beside a non-empty page would be its own wrong answer; the remedy is to enable the index (MEA_INDEXER_ENABLED, lag within MEA_INDEXER_MAX_LAG_BLOCKS), which makes both totalCount and ASC exact. |
/v1/blocks/{stateHash}GET | Partial | null: coinbaseReceiver, snarkedLedgerHash, stagedLedgerHash, winnerAddressisCanonical is INTEGER 0/1 in the detail DTO (§D). Zero snarkersFee/transactionsFee re-emitted as int 0. Not found → empty-body 404, no content-type (§I.10 / §G probe 23). |
/v1/blocks/latest-block-state-hashGET | Full | Bare UNQUOTED string body (§I.2). Content-type is NEGOTIATED from Accept, as upstream does: `Accept: application/json` → application/json, `*/*`/absent → text/plain;charset=UTF-8 (§H.2, M5-T1 live-verified). Repeated `Accept` field lines are joined per RFC 9110 §5.3; a media type that is not a valid `type/subtype` token is treated as absent. Not found → empty-body 404. The response cache stores ONE Accept-independent entry per resource and re-negotiates the content-type at REPLAY time (`app.responses.ACCEPT_NEGOTIATED_PATHS`, applied by `app.cache.replay` on the fresh hit, the single-flight follower and the stale-serve path); keying the cache on the negotiated type instead let a client mint unbounded cache entries and upstream calls. Because the representation varies by Accept while our `vary` value is byte-pinned and cannot list it, this is the ONE route whose additive `Cache-Control` is `private, max-age=5` rather than `public`, so no downstream shared cache may store a negotiated representation. This route is also EXEMPT from the §H.2 406 — Spring negotiates here instead of rejecting. |
/v1/block-confirmation/{txHash}GET | Partial | null: txHashNext, txHashPreviousWindow-bounded. Pending-path also nulls blockHeight, failureReason, stateHash until confirmation. |
| Transactions | ||
/v1/transactionsPOST | Partial | null: fromAccountImg, fromAccountName, toAccountImg, toAccountNameblockHeight is a STRING here (bug-compatible, §C). age = block epoch-ms (§B). totalCount present but window-approximate until the index. |
/v1/transactions/pendingPOST | Partial | null: age, fromAccountImg, fromAccountName, toAccountImg, toAccountNameEnvelope A WITHOUT totalCount (§A). status constant 'pending'; receiverScam/sourceScam emitted as all-null objects (Blockberry parity). |
/v1/transactions/internalPOST | Partial | null: accountImg, accountName, receiverImg, receiverName, secondarySequenceNo, sequenceNo, txHashEnvelope A WITHOUT totalCount (§A). Coinbase + fee-transfer commands. |
/v1/transactions/{hash}GET | Partial | null: alert, balanceChanges, delegationAmount, delegationAmountUsd, receivedAmount, receivedAmountUsd, receivedDelegationAmount, receiverImg, receiverName, sequenceNumber, sourceImg, sourceNamePending path also nulls timestamp + failureReason. USD fields degrade to null on a CoinGecko outage (never 5xx). Not found → empty-body 404. Blockberry divergence (mina-devnet): $.balanceChanges[*].scam — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet ChangeBalanceDto names it `scam`, mainnet `accountScam` (evidence: findings §I.16)Blockberry divergence (mina-devnet): $.balanceChanges[*].isBridge — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet ChangeBalanceDto names it `isBridge`, mainnet `isBridged` (evidence: findings §I.16) |
| Latest | ||
/v1/latest-transactionsGET | Partial | null: fromAccountImg, fromAccountName, toAccountImg, toAccountNameBare JSON array of UserCommand items (§H). blockHeight is a STRING (§C), matching POST /v1/transactions. |
/v1/latest-zkapp-transactionsGET | Partial | null: proverImg, proverNameBare JSON array of ZkAppTransactionDto (§H); identical item to /v1/zkapps/txs. isAccountHijack / isZkappAccount constant false. |
| zkApps | ||
/v1/zkapps/txsGET | Partial | null: proverImg, proverName, updatedAccounts[].verificationKey, updatedAccounts[].verificationKeyHashEnvelope A WITH totalCount. isAccountHijack / isZkappAccount constant false; updatedAccounts[] off-chain fields (accountImg/accountName/isZkappAccount) null, plus the verificationKey pair — see null_fields. |
/v1/zkapps/accounts/{address}/txsGET | Partial | null: proverImg, proverNameEnvelope A WITHOUT totalCount (§A). |
/v1/zkapps/blocks/{stateHash}/txsGET | Partial | null: proverImg, proverNameEnvelope A WITHOUT totalCount (§A). |
/v1/zkapps/txs/{txHash}GET | Partial | null: feePayerImg, feePayerName, updatedAccounts[].verificationKey, updatedAccounts[].verificationKeyHashisAccountHijack / isZkappAccount constant false; updatedAccounts[] off-chain fields (accountImg / accountName / isZkappAccount) null. The verificationKey pair is PARTIALLY served — see null_fields and the #96 entry below. USD via CoinGecko (null on outage). Not found → empty-body 404. #91 CORRECTION (R2 — recorded zkapp-tx-{raw,by-hash} fixture pair on both networks, re-verified live 2026-07-30 against both repro hashes): `updatedAccounts[].update` returned 500 on 50 of 50 sampled confirmed zkApp txs because two of its fields are a strict pydantic `str` that upstream does not always supply as one. (1) `appState` DROPS every slot the account update leaves UNSET — upstream sends all EIGHT slots with null, the wire keeps only the SET ones in order and collapses an update that sets none at all to the single-element [""]; the list is optional but its ELEMENTS are not, so the passthrough raised ValidationError. (2) A SET `update.verificationKey` is the compound {data, hash} upstream and the base64 `data` ALONE on the wire (the hash is discarded, NOT relocated). Siblings audited in the same pass: `permissions` / `timing` are all-null OBJECTS when unset, not JSON null (the *Scam rule); `tokenSymbol` / `votingFor` / `zkappUri` / `delegate` are plain nullable strings and now coerce at the boundary. #96 (2026-08-01) PARTIALLY LIFTS #92's null_fields declaration on `updatedAccounts[].verificationKey` / `.verificationKeyHash`; the two null_fields lines above now record the RESIDUAL gap rather than a blanket null. Before this, Blockberry populated the pair on 31 of 65 sampled account updates and we served null on 65 of 65 (docs/evidence/m4-t4/verification-key-parity-2026-07-30.json). WHAT THE FIELD IS — and #92 had this wrong, which bounds how far the fix goes. It is NOT what the update sets (#91), and it is NOT "the account's CURRENT on-chain VK" (#92): it is the key THE UPDATE WAS PROVED AGAINST, i.e. the account's key AS OF THAT TRANSACTION. For a recent tx the two coincide; for a tx older than that zkApp's most recent key upgrade they do not. Proven live 2026-08-01: account B62qm46jq…FsRcyC has FOUR distinct keys in tests/fixtures/blockberry/mainnet/zkapp-vk-history.json (2026-06-04, -06-18, -06-29, -06-30) and Blockberry's detail for mainnet tx 5Jv2qgdVsN3ekDak1hbXGv75FbQpnFMKKC8BjKyVRZSHmHL8Fnj5 (block 531159, 2026-06-05) serves that account the FIRST of the four (hash 20009185…291261, key data byte-equal to that history entry), not the current 26175615…357420. #92's model was right only on the recent transactions it sampled. THE DIGEST IS FREE AND IS CORRECT FOR ALL OF HISTORY, because it is read off the COMMAND, not off the ledger: `body.authorizationKind.verificationKeyHash` — in the daemon selection since #83 — IS the wire's `verificationKeyHash` whenever `isProved` is true, byte-equal on both recorded networks (mainnet 5JtXGyUb… / B62qm46jq…, devnet 5Jv8M4XA… / B62qpww8A…). Every NON-proved update carries the dummy hash 3392518251…7494 and is null on the wire, so the gate is `isProved`, not the presence of a hash; that is what keeps the fee payer and plain updates null, including a SIGNATURE-authorized update that SETS a VK. THE 2396-CHAR KEY DATA IS NOT: the daemon's account(publicKey:) resolver serves only the BEST TIP, so it can supply the proved-against key only while the account has not upgraded since. It is served ONLY when the account's current hash equals the digest the command names — never a newer key under an older digest, which a client cannot detect as wrong. BOTH OR NEITHER. When the data is unavailable for any reason the HASH IS WITHHELD TOO. Blockberry never splits the pair (recorded fixtures: both 20x, neither 97x, split 0/117; live 31-tx sample: both 31, neither 34, split 0), so a populated hash beside a null key would be a shape upstream never emits. Withholding it also means no response is worse than before #96 — a null pair is exactly what main served. ADDED UPSTREAM COST: at most ONE daemon round trip per response, ZERO when no account update is proof-authorized. Happy path 2 → 3 daemon requests (+50 % on this route). The distinct addresses are aliased into a single account(publicKey:) query capped at ZKAPP_VK_LOOKUP_CAP = 8 (an empirical bound, not a protocol one — exceeding it is COUNTED as mea_zkapp_vk_lookup_truncated and logged, never silently truncated); no archive query is added. BOUNDED IN TIME: one attempt at 2 s, not the client's 20 s x 3 default, and run concurrently with the price fetch. Enrichment must not be able to add a minute to a response, and the circuit breaker cannot stop it from doing so — it opens on CONSECUTIVE failures, and the two successful daemon queries every detail request makes first reset the counter. A daemon that rejects the selection outright is remembered per endpoint, so it is asked once per TTL. It is pure enrichment: daemon down, breaker open, timeout, account absent, hostile shape or hash mismatch all degrade the PAIR to null (R8) and can never reproduce #91's 500. PATH TO FULL PARITY: the indexer's `vk_history` table already records every key change with its timestamp, so resolving authorizationKind.verificationKeyHash against it closes the residual gap for the whole of history with no upstream call at all. Not wired up because MEA_INDEXER_ENABLED is false in production — the gap is a deployment state, not a permanent limit. NOT changed by #96: the LIST surfaces (/v1/zkapps/txs and friends) keep `updatedAccounts[].verificationKey`/`Hash` null. Blockberry is itself inconsistent there — the recorded zkapp-txs.json populates 5 of 16 updates on devnet and 0 of 10 on mainnet — and matching it would put one account lookup per list ITEM on a paged endpoint, which is the unbounded fan-out this fix exists to avoid. #117 (2026-07-31): `updatedAccounts[].update.permissions.setVerificationKey` served `null` here on every permissions-setting update, against Blockberry's `"signature"`. Not a mapper bug — `app/mappers/zkapps.py::_permissions` has always projected the daemon's compound `VerificationKeyPermission {auth, txnVersion}` to its `.auth` — but the daemon selection requested the field BARE, this daemon serialises an un-sub-selected OBJECT as `{}`, and `_wire_str` degrades a container to `null`. The selection now sub-selects `{ auth txnVersion }`, so this field carries the projected auth. The RAW endpoint, which emits no member of this field at all, is a SEPARATE and opposite contract; see the /v1/zkapps/txs/raw/{txHash} entry. #94 CLOSED (2026-07-31) — ONE RESIDUAL, named below. For an update that SETS permissions Blockberry LOWERCASES the auth values (daemon `None`/`Either`/`Proof`/`Signature`/`Impossible` → wire `none`/`either`/`proof`/`signature`/`impossible`, uniformly across all thirteen members, `None` becoming the STRING `"none"` and never JSON `null`) and serves `access` and `setTiming` as `null` however the update set them. Both are now REPRODUCED at the mapper boundary rather than corrected — the wire is this surface's contract (findings §I.18). Pinned by the same transaction recorded on BOTH surfaces and BOTH networks, tests/fixtures/blockberry-followups/{mainnet,devnet}/ zkapp-tx-set-permissions{,-raw}.json (mainnet `5JutnBqe…` block 530906, devnet `5JtfFjq9…` block 540395); the mainnet recording sets `access` to `"Either"` on one update and `"None"` on two others and the wire nulls all three, which is what rules out a value-dependent `None`→`null` mapping. The nulling belongs to THIS surface, not to the fields: /v1/zkapps/{address} populates both. An update that sets no permissions is untouched — still the all-null OBJECT of the *Scam rule. RESIDUAL (#94, open): the nulling rule is OBSERVATION-SCOPED, not proven unconditional. It held on 4 of 4 set-permissions updates across both networks, and all 4 ALSO set a `verificationKey` (mainnet update[0..2], devnet update[1]) — mainnet's tx is the zkApp's DEPLOY, which is why. "Blockberry nulls these two on any update that also sets a VK" therefore fits every observation we have just as well. The distinguishing case is UNOBSERVED: a set-permissions update with NO verificationKey (an admin locking `setDelegate` on an already-deployed zkApp). If Blockberry populates the members there, we serve `null` and diverge on the exact field #94 set out to make byte-exact. The next set-permissions fixture recorded should deliberately be a NON-VK-SETTING update; until then the mapper keeps the broader rule, which is the best one R2 supports. |
/v1/zkapps/txs/raw/{txHash}GET | Partial | null: id, zkappCommandM5-T1 CORRECTION (§I.14): the published spec types this `string`, but the live wire returns the OBJECT {failureReason, hash, id, zkappCommand} on BOTH networks — we now emit that object (previously a JSON-encoded string, a real drop-in break). `zkappCommand` is the wire's own envelope {accountUpdates, feePayer, memo} — the first M5-T1 fix double-nested it under a second `zkappCommand` key and leaked duplicate hash/failureReason; that was caught in review and corrected. `accountUpdates[].body.update` and `balanceChange` match the wire key-for-key. INTERIOR NOW COMPLETE (#83, 2026-07-30): the nine fields §I.14 recorded as absent — feePayer.authorization, feePayer.body.validUntil, accountUpdates[].authorization, and accountUpdates[].body.{actions, authorizationKind, events, implicitAccountCreationFee, mayUseToken, preconditions} — were a query-selection gap, not a data-availability one, and the daemon selection in app/upstream/zkapp_daemon.py now requests them. Verified live on both networks: a frontier zkApp command fetched through the production query has a recursive key-path set IDENTICAL to the recorded fixture's, in the same key order. A TENTH field of the same kind shipped with them: accountUpdates[].body.update.verificationKey is an OBJECT (VerificationKeyWithHash {data, hash}) that was selected but never SUB-selected, and this daemon serialises an unsubselected object as {} rather than erroring — so we served update.verificationKey = {} where Blockberry serves {data: 2396 chars, hash: 76 chars}, and vk_history would have taken ZERO rows instead of rows with a null hash (silently emptying the VK surfaces and /v1/info zkappsCount). That closes the 2026-07-30 smoke run's open question, which had provisionally filed the {} as a possible archive data-availability difference; it was not. #117 (2026-07-31) DELIBERATE AND PERMANENT: `accountUpdates[].body.update.permissions.setVerificationKey` is emitted as `{}` here even though the daemon now supplies the whole `VerificationKeyPermission {auth, txnVersion}`. Blockberry emits NO member under this field on this surface — docs/evidence/m3-t1/devnet-head-follow/raw/blockberry-compare.json matched 150/150 leaf paths with `paths_only_in_blockberry: []` on the same devnet tx — so `app/mappers/zkapps.py::to_wire_zkapp_command` folds it back at the raw endpoint's own mapper boundary and nothing this route serves changed. The wider object exists for the INDEXER (`zkapp_account_updates.update_body`, a frontier-only column) and for the DETAIL route `/v1/zkapps/txs/{txHash}`, where Blockberry DOES serve the compound's `.auth` and we therefore do NOT fold — the two surfaces have opposite contracts and each is separately captured. `failureReason` collapses our per-account-update list to the wire's scalar by taking the first reason — UNVERIFIED, since the corpus contains no failed zkApp tx on either network. Miss → empty-body 404. |
/v1/zkapps/txs/balance-change/{txHash}GET | Partial | null: accountImg, accountName, balanceBefore, balanceAfter, coingeckoCoinId, coinmarketCoinId, isBridged, isFeeIncluded, isVerified, isZkappAccount, tokenImage, tokenName, tokenScam, tokenSymbolaccountScam emitted as all-null object (Blockberry parity). Blockberry divergence (mina-devnet): $[*].scam — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet ChangeBalanceDto names it `scam`, mainnet `accountScam` (evidence: findings §I.16)Blockberry divergence (mina-devnet): $[*].isBridge — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet ChangeBalanceDto names it `isBridge`, mainnet `isBridged` (evidence: findings §I.16) |
/v1/zkapps/{address}GET | Partial | null: verificationKeyHashInvalid / nonexistent / non-zkApp address → HTTP 200 all-null object, NOT 404 (§I.9). accountScam emitted as all-null object (parity). Wire shape UNVERIFIED (no live key, plan §7.1). This route is daemon-first, so it serves the account's live on-chain verificationKeyHash whenever the daemon has one; only the index FALLBACK is blocked on #83. `permissions` CASING RESOLVED — #122 (fixed 2026-08-01, was the KNOWN DIVERGENCE this entry carried from #121): the auth values were served in DAEMON casing here (`access: None`, `send: Proof`, `setVerificationKey: Signature` — daemon account recorded in docs/evidence/m1-followups/getzkappbyaddress.json) where Blockberry lowercases them (`none` / `proof` / `either` / `signature` / `impossible`, recorded on BOTH networks in tests/fixtures/blockberry/{mainnet,devnet}/zkapp-by-address.json). #94 established the lowercasing as a HOUSE CONVENTION of Blockberry's serializer rather than a property of the tx-detail endpoint (findings §I.18), which is what made this surface a consequence of it, so app/mappers/accounts.py::_map_zkapp_permissions now SHARES #94's normalisation — app.mappers.zkapps.wire_auth, promoted to a public name for the purpose — rather than carrying a second copy of the rule. Pinned by tests/test_gate_m1_t5.py::test_zkapp_by_address_permissions_match_the_recorded_wire_byte_for_byte, which round-trips the whole thirteen-member recorded object on both networks. What deliberately did NOT come across: #94's `access`/`setTiming` NULLING is per-SURFACE — Blockberry POPULATES both members here (`access: none`, `setTiming: proof` on mainnet / `impossible` on devnet) — and ::test_zkapp_by_address_keeps_access_and_set_timing_populated asserts against `_WIRE_NULL_PERMISSIONS` itself so a later refactor cannot spread the tx-detail rule to this route. |
/v1/zkapps/{address}/last-verification-key-changeGET | Full | Bare int64 epoch-ms of the last vk change. M5-T1 CORRECTION (§I.15): "no VK change" is an EMPTY 200 with NO content-type — live-verified on both networks — NOT the bare null M3-T7 extrapolated from §I.9. Wire shape now VERIFIED. |
/v1/zkapps/{address}/verification-key-historyGET | Partial | null: verificationKeyHashBare ascending array of VerificationKeyHistoryDto; empty array on miss. Wire shape UNVERIFIED (M3-T7). NOTE the same #83 dependency empties the ARRAY, not just this field — vk_history gets no rows at all until the daemon selection is widened. |
| Accounts | ||
/v1/accounts/{address}GET | Partial | null: accountName, accountImg, description, discordNicknames, firstSeen, lastSeen, isNotActivated, isVerified, socialDiscord, socialEmail, socialGitHub, socialTelegram, socialTwitter, websiteInvalid / nonexistent address → HTTP 200 all-null AccountInfo, NOT an error (§I.9). accountScam emitted as all-null object (parity). balance reproduces Blockberry's double-division bug (nanomina ÷ 1e18) on THIS endpoint only; /balance uses the correct ÷1e9. Blockberry divergence (mina-devnet): $.scam — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet names the field `scam`, mainnet/documented `accountScam` (evidence: findings §I.16) |
/v1/accounts/{address}/balanceGET | Full | MINA + USD balance (correct ÷1e9). balanceUsd degrades to null on a CoinGecko outage (never 5xx). |
/v1/accounts/{address}/delegateeGET | Partial | null: epoch1, epoch2currentEpoch populated (delegate + delegate balance); currentEpoch.delegateeImg/delegateeName null (off-chain); currentEpoch.accountScam all-null object (parity). Blockberry divergence (mina-devnet): $.accountScam — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet returns the FLAT single-delegatee DTO, mainnet the {currentEpoch, epoch1, epoch2} wrapper (evidence: findings §I.16)Blockberry divergence (mina-devnet): $.delegateeAddress — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet returns the FLAT single-delegatee DTO, mainnet the {currentEpoch, epoch1, epoch2} wrapper (evidence: findings §I.16)Blockberry divergence (mina-devnet): $.delegateeImg — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet returns the FLAT single-delegatee DTO, mainnet the {currentEpoch, epoch1, epoch2} wrapper (evidence: findings §I.16)Blockberry divergence (mina-devnet): $.delegateeName — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet returns the FLAT single-delegatee DTO, mainnet the {currentEpoch, epoch1, epoch2} wrapper (evidence: findings §I.16) |
/v1/accounts/{address}/txsGET | Partial | null: accountName, accountImg, receiverName, receiverImg, senderName, senderImgEnvelope A WITHOUT totalCount (§A). blockHeight is an INTEGER here (§C, contrast the tx list's string). isAmountChangeable constant false; receiverScam/sourceScam all-null objects (parity). |
/v1/accounts/{address}/timing-infoGET | Partial | null: cliffTimeMillis, vestingTime, vestingTimeMillis, unlockStatus, liquidityRate, startTimeM1-T8. isHasLock semantic UNVERIFIED (no fixture). Absent address → all-null at 200; daemon outage → 503 (M2-T3, never a raw 500). |
/v1/accounts/{address}/is-validatorGET | Full | Bare JSON boolean (§I). Window-approximate until the M3 index; wire shape UNVERIFIED. |
/v1/accounts/{address}/is-snarkerGET | Full | Bare JSON boolean (§I). Window-approximate until the M3 index; wire shape UNVERIFIED. |
/v1/accounts/{address}/statisticsGET | Partial | null: minBalance, maxBalanceM3-T5, index-backed. Counts (incomingTxs/outgoingTxs/zkappTxs/totalTxs) real from the transactions projection. Mainnet wire shape VERIFIED live (M5-T1); upstream devnet has NO such route (405, §F.2) — we serve it on every network. Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register this route: upstream devnet 405s it with the unknown-path HandlerMethod body while WE serve it with real data on every network (§F.2). Whether to reproduce devnet's routing asymmetry is an OPEN operator decision — see findings §I.16, 'The ROUTE half of that decision is still OPEN' (evidence: findings §F.2) |
/v1/accounts/{address}/staking-balanceGET | Partial | Bare EpochDotDTO[] from staking_ledger; EMPTY on any network with no GCS staking source. Both networks served today have one, so the only way to get a short array here is a chain (or a dump history) younger than the horizon — short by history, not by horizon, and the two are not the same complaint. Mainnet wire shape VERIFIED live (M5-T1); upstream devnet has NO such route (405, §F.2) — we serve it on every network. The series is BOUNDED BY THE INGEST HORIZON (#98): staking_ledger holds only the MEA_STAKING_EPOCH_DEPTH most recent epochs (default 8 ≈ 2 months of mainnet epochs at the post-mesa-upgrade 90 s slot; it was ≈ 4 months at the historical 180 s), so epochs older than that are ABSENT from the array rather than present with a zero balance. Widening the horizon needs a config change plus a re-ingest. Those epochs are counted back from the epoch the DAEMON reports, not from the highest epoch number in the bucket (#103) — mainnet's dump bucket holds a dead pre-fork era numbered 0-79 beside the live 0-52 — so the array is dense over the live chain's recent epochs and never carries a pre-fork one. If the daemon cannot be reached the ingest writes nothing at all, and the array is EMPTY rather than stale. Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register this route: upstream devnet 405s it with the unknown-path HandlerMethod body while WE serve it with real data on every network (§F.2). Whether to reproduce devnet's routing asymmetry is an OPEN operator decision — see findings §I.16, 'The ROUTE half of that decision is still OPEN' (evidence: findings §F.2) |
/v1/accounts/{address}/tx-count-chartGET | Partial | Bare DataPointDto[] from the transactions projection (real). Mainnet wire shape VERIFIED live (M5-T1); upstream devnet has NO such route (405, §F.2) — we serve it on every network. Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register this route: upstream devnet 405s it with the unknown-path HandlerMethod body while WE serve it with real data on every network (§F.2). Whether to reproduce devnet's routing asymmetry is an OPEN operator decision — see findings §I.16, 'The ROUTE half of that decision is still OPEN' (evidence: findings §F.2) |
/v1/accounts/{address}/zkapp-tx-count-chartGET | Partial | Bare ZkTxDataPointDto[] from the transactions projection (real). Mainnet wire shape VERIFIED live (M5-T1); upstream devnet has NO such route (405, §F.2) — we serve it on every network. Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register this route: upstream devnet 405s it with the unknown-path HandlerMethod body while WE serve it with real data on every network (§F.2). Whether to reproduce devnet's routing asymmetry is an OPEN operator decision — see findings §I.16, 'The ROUTE half of that decision is still OPEN' (evidence: findings §F.2) |
| Rich list | ||
/v1/accountsGET | Partial | null: accountName, accountImg, accountScam, delegateName, delegateImg, delegateScam, receiptChainHash, isZkappAccountObserved-addresses-only rich list from the #19 seen_accounts projection (NOT a full-ledger snapshot; plan §7.2). Envelope A cut page WITH totalCount (true row count); totalElements capped at 10 000. sortBy BALANCE|NONCE, orderBy ASC|DESC; size>50 / bad sortBy → Spring 400. R8: index off → valid empty page, never 500. Wire shape UNVERIFIED (plan §7.1). |
| Info | ||
/v1/infoGET | Partial | null: nftsCount, collectionsCount, accountsCount, accountsCountDay, activeSnarkersCount, activeValidatorsCount, avgSnarkFee, blockHeightDay, paymentsCount, paymentsCountDay, snarkersCount, snarkersCountDay, tokensCount, txsCount, txsCountDay, validatorsCount, zkAppAccountsCount, zkappTxsCount, zkappTxsCountDay, zkappsCount, zkappsCountDayDashboardDto. Real (window-derived from archive/daemon): blockHeight, epoch, slot (post-fork), avgFee, avgFeeDay, maxTxFee, minTxFee. epoch/slot are POST-FORK values (M1-T9). |
| Validators | ||
/v1/validatorsGET | Partial | null: networkShare, stakePercent, validatorFeeEnvelope B (true content-keyed Spring Page; totalElements never capped) — mainnet-generation, served on every network (§I.16). Real: validatorAddress, canonicalBlocksCount, allBlocksCount, stake, delegatorsCount, nextEpochStake, nextEpochDelegationsCount; scam is the all-null object (parity). Also null (data gap): diff* (e.g. diffStake), epoch2*, and all identity/social/verification fields. stake fields are null on a network with no GCS staking source; both networks served today have one, so stake / nextEpochStake / delegatorsCount / nextEpochDelegationsCount are populated once the indexer runs. On a YOUNG chain the horizon is bounded by the CHAIN rather than by MEA_STAKING_EPOCH_DEPTH — the era walk stops at epoch 0 and depth 8 is never reached, because deeper history does not exist yet. That was measured on mesa-rc (verified 2026-07-31: daemon on epoch 1, bucket holding epochs 0-1, era walk admitting 4 objects) before that network was decommissioned; it is the general shape for any freshly forked chain. R8: index off → empty/zero/null, never 500. STAKING HORIZON (#98, operator decision 2026-07-30): the indexer ingests only the MEA_STAKING_EPOCH_DEPTH most recent epochs per network — default 8, i.e. ~2 months at mainnet's 7140 slots x 90 s ≈ 7.4-day epochs (it was ~4 months before the mesa upgrade halved the slot from 180 s). That COVERS current- and next-epoch stake, which is exactly what stake / nextEpochStake / delegatorsCount / nextEpochDelegationsCount read here, so those fields are unaffected. It does NOT cover delegation history deeper than that ~2-month horizon; widening it is a config change plus a re-ingest, not a code change. WHICH epochs those are is fixed by the daemon, not by epoch number (#103): the dump buckets number epochs from 0 on EVERY chain era, so mainnet's holds a dead pre-fork 0-79 beside the live 0-52, and the ingest anchors on the epoch-ledger hashes the daemon reports. Verified 2026-07-30 against the live buckets: mainnet selects epochs 45-52 (the era-blind predicate selected 72-79, dumped 2024), devnet 49-56. If no anchor can be obtained the ingest writes nothing, so these fields degrade to null/zero (R8) rather than carrying a dead era's stake. Blockberry divergence (mina-devnet): $.data — page-content key renamed on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet still serves the legacy data+totalCount envelope where mainnet (and we) serve a true content-keyed Spring Page. Encoded as a RENAME so the ValidatorDTO item schema is still applied to devnet's `data` array (evidence: findings §I.16)Blockberry divergence (mina-devnet): $.totalCount — extra field on the upstream wire. Blockberry serves an OLDER build on devnet whose DTOs predate the published contract: devnet still serves the legacy data+totalCount envelope (evidence: findings §I.16) |
/v1/validators/{address}GET | Partial | null: networkShare, stakePercent, validatorFeeSame null set + diff*/epoch2*/identity as the list. Absent/not-indexed → all-fields object with the address echoed + zero block counts, never 500. Wire shape UNVERIFIED (plan §7.1). Same ~4-month staking horizon as the list (#98): current- and next-epoch stake are covered, deeper delegation history is not ingested at MEA_STAKING_EPOCH_DEPTH=8. Same era anchoring as the list (#103): the horizon is counted back from the daemon's epoch, so a pre-fork ledger is never served here either. |
/v1/top-validatorsGET | Partial | null: networkShare, stakePercent, validatorFeeServes the SAME ValidatorDTO as /v1/validators via the identical map_validator (8 of 31 fields populated), so diff* (diffStake/diffDelegatorsCount), epoch2* (epoch2Stake/epoch2DelegationsCount), isActive, isVerified, isStakingRewardsVerified, and all identity/social/terms fields are null too — the live devnet recording (tests/fixtures/blockberry/devnet/top-validators.json) shows Blockberry populates networkShare/stakePercent/isActive/isVerified, so these are real divergences. ROUTING is exact per network — config-driven asymmetry (§F, M3-T3 reprobe): devnet → bare ValidatorDTO[] (count 1–50); mainnet → byte-identical 405 {"error": "Handler is not instance of HandlerMethod"} (route absent upstream); a network flagged active: false → 503. |
| Snarks | ||
/v1/snarkersGET | Partial | null: accountName, accountImg, fee, isZkappAccount, snarkJobsCount, snarkWorksCount, totalJobsSoldEnvelope A data-keyed, NO totalCount, NO 10k cap (§I.4). Real: accountAddress, snarkAmountEarned, firstSeen, lastSeen. Blockberry divergence (mina-mainnet, mina-devnet): $.data — page-content key renamed on the upstream wire. The generated model/published spec declare a `content`-keyed Spring Page; the live wire is `data`-keyed on both networks and so are we. Encoded as a page-content-key RENAME, not a suppressed extra key: parity_diff recurses into `data` with the `content` item schema, so every item stays structurally validated (a suppression left the whole array unchecked) (evidence: findings §I.4) |
/v1/snarks/{stateHash}/jobsGET | Partial | null: accountName, accountImg, workIdsEnvelope A data-keyed, NO totalCount. M5-T1 CORRECTION: was Envelope B per the merged spec and flagged UNVERIFIED; the live wire is data-keyed on both networks, so the emitter moved to cut_page (§I.4). Wire shape now VERIFIED. Real: accountAddress, fee, isCanonical, timestamp. Blockberry divergence (mina-mainnet, mina-devnet): $.data — page-content key renamed on the upstream wire. The generated model/published spec declare a `content`-keyed Spring Page; the live wire is `data`-keyed on both networks and so are we. Encoded as a page-content-key RENAME, not a suppressed extra key: parity_diff recurses into `data` with the `content` item schema, so every item stays structurally validated (a suppression left the whole array unchecked) (evidence: findings §I.4) |
/v1/snarks/jobs/{address}GET | Partial | null: workIdsEnvelope A data-keyed, NO totalCount, NO 10k cap. M5-T1 CORRECTION: was emitted as Envelope B per the merged spec and flagged UNVERIFIED; the live wire is data-keyed with an UNCAPPED totalElements (observed 5 131 516) on both networks, so the emitter moved to cut_page (§I.4). Wire shape now VERIFIED. Real: blockHeight, fee, isCanonical, stateHash, timestamp. Blockberry divergence (mina-mainnet, mina-devnet): $.data — page-content key renamed on the upstream wire. The generated model/published spec declare a `content`-keyed Spring Page; the live wire is `data`-keyed on both networks and so are we. Encoded as a page-content-key RENAME, not a suppressed extra key: parity_diff recurses into `data` with the `content` item schema, so every item stays structurally validated (a suppression left the whole array unchecked) (evidence: findings §I.4) |
/v1/snarks/jobs/{address}/snarker-by-addressGET | Partial | null: accountName, accountImg, avgAllTimeFee, avgMonthFee, currentFee, delegateeAddress, jobsProduction, snarkJobsCount, worksProducedReal: feesEarned, firstSeen, lastSeen. Unknown prover → 200 all-null. Wire shape UNVERIFIED. |
| Tokens | ||
/v1/tokensGET | Partial | null: lockedBalance, unlockPercent, isZkappAccount, ownerAddress, ownerName, ownerImg, ownerTokenId, ownerScam, tokenName, tokenImage, isVerified, isBridged, coingeckoCoinId, coinmarketCoinId, tokenScamEnvelope A WITHOUT totalCount. Wire shape UNVERIFIED (plan §7.1). tokenSymbol is NOT always-null: the #19 projection writes token_activity.token_symbol from the accountUpdate's update.tokenSymbol (#73 / #98 A.3) and this endpoint reads MAX(token_symbol) per token id, so a CUSTOM token's symbol is null only until the indexer walks a block that sets one. The NATIVE token is the exception in both directions: its tokenSymbol is the protocol constant MINA (never an index read) and its tokenType is FUNGIBLE_TOKEN, not CUSTOM_TOKEN (#145). |
/v1/tokens/symbolsGET | Partial | Flat {tokenId: SYMBOL} string map, NO envelope (§E/§I.6, PINNED). Upstream Blockberry 500s on mina-devnet for this path (§F.2, stable 4/4) — we serve it normally on every network. NOT unconditionally empty: #73 / #98 A.3 makes the #19 projection write token_activity.token_symbol, so the map fills with CUSTOM token symbols as the indexer walks blocks that set one, and the NATIVE token id is ALWAYS an entry mapped to MINA — a protocol constant, not an index read (#145; Blockberry's own map on mina-mesa, probed 2026-08-04 before that network was decommissioned, was exactly {"wSHV2S4q…": "MINA"}). The map is empty ONLY on the R8 degrade paths (Postgres disabled / query failed). Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's devnet 500s this path (stable, 4/4 across two probe rounds) — an upstream FAULT, not a contract. We serve it normally on every network and deliberately never fabricate a 5xx (R8), so our status diverges from the recorded devnet wire (§F.2) (evidence: findings §F.2) |
/v1/tokens/{tokenId}GET | Partial | null: lockedSupply, unlockedSupply, unlockPercent, tokenHoldersCountDay, tokenAdminAddress, ownerAddress, tokenOwnerPk, ownerName, ownerImg, ownerScam, tokenName, tokenImage, isVerified, isBridged, coingeckoCoinId, coinmarketCoinId, tokenScamUnknown tokenId → empty-body 404 (§I.10, UNVERIFIED). Wire shape UNVERIFIED (plan §7.1). NATIVE token id (wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf): we answer 200 where Blockberry answers an empty-body 404 (probed live 2026-08-04 on mina-mainnet). DELIBERATE (#145), not accidental: their registry is an off-chain CUSTOM-token registry the native token was never in (it is likewise absent from their recorded 86-entry mainnet /v1/tokens/symbols map), whereas ours is index-derived and /v1/tokens returns the id as its sole FUNGIBLE_TOKEN — 404ing a detail route for an id our own list serves would be a new divergence between our own surfaces to remove an old one with theirs. tokenSymbol is MINA and tokenType FUNGIBLE_TOKEN; totalSupply / tokenHoldersCount stay the documented PARTIAL zkApp-derived slice (token_activity carries no payments / coinbase / fee-transfers), the same basis the list already serves. Pinned by tests/test_gate_i145.py. |
/v1/tokens/{tokenId}/holdersGET | Partial | null: lockedBalance, movableBalance, holderName, holderImg, accountScamHolder isZkappAccount IS real (seen_accounts join). Wire shape UNVERIFIED. |
/v1/tokens/{tokenId}/txsGET | Partial | null: fee, memo, nonce, proverAddress, proverImg, proverName, statusShares the ZkAppTransactionDto item mapper (map_token_tx → to_list_item), so it inherits that DTO's off-chain prover nulls on top of the token_activity gaps. proverScam/accountScam all-null objects (parity). Wire shape UNVERIFIED. |
/v1/tokens/accounts/{address}GET | Partial | null: nonce, tokenName, tokenImage, isVerified, isBridged, coingeckoCoinId, coinmarketCoinId, tokenScamPer-account token holdings (TokenByPkDto). Wire shape UNVERIFIED. |
| Time-locks | ||
/v1/time-locksPOST | Partial | null: name, img, totalSupply, isZkAppAccountVesting math is real, live-verified (M1-T8; app/vesting.py). Envelope A WITHOUT totalCount; totalElements capped 10 000. lockTypes body accepted + enum-validated (invalid → Spring 400) but NOT yet used to filter (LOCKED/UNLOCKED semantics UNVERIFIED). Date fields (cliffTimeMillis/vestingTimeMillis/lockDate/fullyUnlockedDate) computed via the original-genesis anchor; exact wire semantics UNVERIFIED. R8: index off → empty page, never 500. |
/v1/time-locks/dayGET | Partial | Per-day unlock aggregation (LocksEntity). Vesting/unlock totals are real + live-verified (M1-T8), property-tested for conservation; envelope + date-field wire semantics UNVERIFIED. R8: index off → empty page, never 500. |
/v1/time-locks/monthGET | Partial | Per-month unlock aggregation (LocksMonthEntity). Vesting/unlock totals real + live-verified (M1-T8); envelope + date-field wire semantics UNVERIFIED. |
/v1/time-locks/yearGET | Partial | Per-year unlock aggregation (LocksYearEntity). Vesting/unlock totals real + live-verified (M1-T8); envelope + date-field wire semantics UNVERIFIED. |
| Security | ||
/v1/security/scamsGET | Stub | No scam registry backs this deployment (§4) → bare empty [] (ScamDto[]) for every query. onChainIds kept REQUIRED (missing → Spring 400), matching the upstream contract (M1-T9). |
/v1/security/scams/tx-bodyPOST | Stub | No scam registry (§4) → bare empty [] (ScamDto[]). Body validated as WalletTransactionDto (malformed → Spring 400), upstream-parity (M1-T9). |
| MIPs | ||
/v1/mipsGET | Stub | Governance-metadata group not served (feasibility tier T4, plan §4). 404 {"error": "Not available on this network"} — a KNOWN-but-stubbed group, so a 404 stub, NOT the 405 unknown-path body (§F/§G/§I.8, M1-T9). Upstream serves it on mainnet and 405s it on devnet (§F.2). Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register the MIPs group at all (upstream devnet 405s it); we answer with the REGISTERED-but-stubbed 404 {"error": "Not available on this network"} on every network, so the status AND the body differ from upstream devnet (§F.2). Open operator decision — findings §I.16 (evidence: findings §F.2) |
/v1/mips/votesGET | Stub | 404 stub (T4). Registered BEFORE /mips/{id} so the literal is not captured by the int path param (M1-T9). Upstream serves it on mainnet and 405s it on devnet (§F.2). Blockberry divergence (mina-devnet): $ — different HTTP status. Blockberry's OLDER devnet build does not register the MIPs group at all (upstream devnet 405s it); we answer with the REGISTERED-but-stubbed 404 {"error": "Not available on this network"} on every network, so the status AND the body differ from upstream devnet (§F.2). Open operator decision — findings §I.16 (evidence: findings §F.2) |
/v1/mips/{id}GET | Stub | 404 stub (T4, M1-T9). |
/v1/mips/{id}/votesGET | Stub | 404 stub (T4, M1-T9). |