<!-- Published copy of the A3MC Solana API handbook. Source of truth: the API repository, revision 604d868 (2026-08-14). Published on art3mis.cloud 2026-09-08. -->

> **Canonical URL:** https://art3mis.cloud/api/a3mc-solana-api.md
> **API base URL:** https://get.art3mis.cloud
> **Operator:** ART3MIS.CLOUD Limited (BVI 2195776), contact a3mc@protonmail.com
> **Terms:** public, unauthenticated, read-only, JSON, CORS open, fair use; the CDN in front of the API applies rate limits.
> **Contract:** this document is the API contract; if something is not in it, it is not part of the API.
> **Revision:** source 604d868 (2026-08-14). Audited against production on 2026-09-08 (epoch 1031): 80 behavioural checks covering every endpoint, parameter validation, pagination limits, filter semantics, the 38 filterable event types, error envelopes, and the documented quirks; all consistent with this document.

# A3MC Solana API — Handbook

Complete reference and reasoning guide for the A3MC Solana observability API. This document is
self-contained: it specifies every endpoint, every parameter, every response field, the provenance
and coverage of every data stream, and the Solana domain semantics required to interpret them. It
is written to be loaded into an autonomous agent's context so the agent can answer arbitrary
questions about the Solana mainnet validator set — from single-fact lookups to multi-endpoint
reconstructions — without access to the source code.

**This document is complete on its own.** An agent needs no other file, no source code, and no
schema knowledge to use this API — if something is not in this handbook, it is not part of the
API's contract. Verified against production on 2026-07-05 (epoch 997); every endpoint re-validated on 2026-09-08 (epoch 1031).

**Base URL (production):** `https://get.art3mis.cloud`

All endpoints are GET, JSON (except `/api/summary/md`), unauthenticated, read-only.

---

## Question routing

Start here. Match the question to a row, call the endpoint, read the referenced sections before
interpreting the result.

| Question is about… | Endpoint(s) | Read |
|---|---|---|
| Current epoch, "now", time remaining | `/api/epoch` | §5.2 |
| Epoch ↔ date conversion, epoch durations | `/api/epochs` | §5.3, §7.13 |
| Current validators: filter, rank, count | `/api/validators` | §5.4, §7.2, §7.10, §7.14 |
| One validator, complete picture | `/api/validators/:pubkey` + `/api/history?validator=` | §5.5, §7.3 |
| Any change over time (commission, IP, version, stake, delinquency, identity, …) | `/api/history` + event type | §5.6, §6 |
| "How many X happened in epoch N" | `/api/history` with filters, `page_size=1`, read `total_items` | §7.1, §7.14 |
| Infrastructure migrations as one story | `/api/history/batches` | §5.7 |
| Pools: current size and flow | `/api/pools` | §5.8, §2.7 |
| Who is in pool X, per-validator stake (whole grid, one call) | `/api/pools/validators` | §5.8b |
| Pools: behavior across epochs | `/api/history` pool event types | §7.7, §7.8, §6 rules |
| Where is stake on the map (now or at epoch N) | `/api/map/distribution` | §5.11, §7.4 |
| Who physically moved, where, how far | `/api/map/movements` | §5.12, §7.5 |
| Datacenter / provider concentration risk | `/api/map/colocations` | §5.13, §7.6 |
| Raw "what happened in epoch N" text digest | `/api/summary?raw=true` | §5.9 |
| Marinade Select bonds: state, flows, one bond | `/api/marinade-bonds*` | §5.14–5.17, §7.12, §2.9 |
| Delinquency triage: real incident, dead corpse, or departure in progress? | `/api/validators?is_delinquent=true` + `delinquency_change`/`stake_change` history | §2.4 |
| Is the data older than the question? | coverage table | §3 |

Before trusting any computed answer, scan §8 (pitfalls) — most wrong answers from this API come
from the items listed there, not from the endpoints.

---

## Table of contents

1. [What this system is](#1-what-this-system-is)
2. [Solana domain model](#2-solana-domain-model)
3. [Data provenance and coverage](#3-data-provenance-and-coverage)
4. [Global API conventions](#4-global-api-conventions)
5. [Endpoint reference](#5-endpoint-reference)
6. [Event-type catalog](#6-event-type-catalog)
7. [Answer recipes](#7-answer-recipes)
8. [Pitfalls and invariants](#8-pitfalls-and-invariants)

---

## 1. What this system is

The platform continuously observes the Solana mainnet validator set and records **state and every
state transition** since 2025-10-10 (epoch 862). Collection daemons poll the cluster (gossip,
vote accounts, block production, stake program accounts), diff each observation against the stored
state, and append typed events to an event log. The API serves three kinds of knowledge:

1. **Current state** — the latest observed snapshot of every validator, pool, and bond.
2. **History** — the append-only event log of every observed change (4.07 M events; 38 filterable
   event types plus 4 realtime delta types, §6).
3. **Aggregations** — geographic/network stake distribution, IP migrations, datacenter
   colocations, computed server-side from state + history.

Eighteen endpoints in five families:

| Family | Endpoints | Question answered |
|---|---|---|
| Cluster clock | `/api/epoch`, `/api/epochs` | What time is it on Solana? How long do epochs take? |
| Validators | `/api/validators`, `/api/validators/:pubkey` | Who runs the cluster right now, with what stake, software, performance, location? |
| History | `/api/history`, `/api/history/batches` | What changed, when, for whom? |
| Pools & aggregates | `/api/pools`, `/api/pools/validators`, `/api/map/distribution`, `/api/map/movements`, `/api/map/colocations`, `/api/summary`, `/api/summary/md` | Where is delegated stake? Where is the hardware? Who moved? |
| Marinade bonds | `/api/marinade-bonds`, `…/snapshots`, `…/events`, `…/:bond_pubkey/history` | What collateral do Marinade Select validators post, and how does it move? |
| Health | `/api/health` | Is the API and its database up? |

---

## 2. Solana domain model

The minimum Solana knowledge required to interpret the data correctly.

### 2.1 Identity pubkey vs vote pubkey

Every validator operates two key pairs:

- **Identity pubkey** — the node's network identity. Appears in gossip, signs blocks, owns the
  leader schedule slots. Operators sometimes **rotate** identities (hardware migration, failover).
- **Vote pubkey** — the vote account. Holds delegated stake, accrues vote credits, sets
  commission. Stable across identity rotations; it is the durable "who" of a validator.

In this API, `identity_pubkey` is the primary key of a validator, but the platform tracks
identity rotations (`identity_change` events) and keeps all historical identities linked.
`/api/validators/:pubkey` resolves the current identity, the vote pubkey, **or any historical
identity** to the same validator. When correlating with external sources, prefer the vote pubkey.

### 2.2 Epochs, slots, and the cluster clock

- An epoch is 432,000 slots; a slot targets ~400 ms, so an epoch lasts roughly 2 days
  (observed durations vary; `/api/epochs` gives exact measured boundaries and durations).
- Stake state changes (activation, deactivation) take effect **at epoch boundaries**.
- The **leader schedule** — which identity produces which slots — is computed about one epoch in
  advance from stake weights, keyed by **identity pubkey**.
- Events in the history log carry the epoch **at observation time**; a change is attributed to the
  epoch during which the daemon saw it.

### 2.3 Stake states

Delegated stake in a stake account is at any time in one of: **activating** (warming up, counts
from next boundary), **active** (earning, counted in leader-schedule weight), **deactivating**
(cooling down), or inactive. The pool statistics expose these three buckets per pool
(`active/activating/deactivating_accounts_stake_lamports`) — together they describe capital flow:
a large activating bucket means inflow committed but not yet productive; a large deactivating
bucket predicts outflow at the next boundary.

### 2.4 Delinquency, corpses, and departures — reading liveness correctly

A validator is **delinquent** when it has stopped voting on recent slots (the RPC cluster marks it
delinquent once its last vote falls too far behind the tip). Delinquency is a liveness signal, not
a performance grade: it flips on crashes, network partitions, and migrations, and flips back on
recovery. The platform records every transition as a `delinquency_change` event (boolean), and the
current flag on each validator (`is_delinquent`).

**The delinquent set is three different populations.** Treating it as one number produces wrong
answers; segment it before any analytics.

1. **Incidents** — live operators having an outage. Signature: down-since is recent (hours to
   ~7 days), stake is meaningful, the validator has a history of uptime, and the flip often
   coincides with an infrastructure batch (`ip_change`, `version_change` — check
   `/api/history/batches`). Recovery is the expected outcome. **Stake-weighted delinquency is
   dominated entirely by this group.** A cluster-wide incident shows as many validators flipping
   in the same window — test by counting `delinquency_change` per epoch and correlating the
   spike's members by ASN/country.

2. **Corpses** — dead validators. Delinquent for weeks or months, stake drained to ≈0, never
   recovering. They stay in the delinquent list indefinitely because the vote account still
   exists on-chain — **they silently pollute every delinquency statistic until explicitly
   excluded.** Duration-weighted "longest delinquent" rankings are always corpses. They also emit
   noise: residual dust delegation oscillates (0↔2 SOL) and produces ongoing `stake_change`
   events long after death. Exclude them unless the question is about them.

3. **Departures in progress** — the decay path between the two: went down, stayed down, and the
   capital is leaving — `stake_change` events draining toward 0, pool memberships flipping to
   `false`. Read this as "probably departing the ecosystem now." The terminal state is
   **departed**: the vote account disappears from the cluster, the platform stamps the validator
   departed, and it drops out of `/api/validators` entirely (still reachable via
   `/api/validators/:pubkey` and `/api/history`).

Lifecycle: healthy → incident → (recovery | stays down → delegator exodus → dust corpse →
departed).

**Classification recipe** (all from the API): for each delinquent validator take (a) down-since =
`event_time` of its latest `delinquency_change → true`, (b) current `stake`, (c) the recent
`stake_change` trajectory. Rules of thumb: down ≤ 7 days → incident; down > 30 days with ≈0 stake
and no inflow → corpse; down with stake actively draining and pool exits → departure in progress.
The 7–30-day band is gray — classify by the stake trajectory, not the clock.

Measured reality (2026-07-06, epoch 997): 26 delinquent validators split into 17 incidents
(≤7 days, carrying 85,337 SOL), 4 gray (7–30 days, 8,059 SOL), and 5 corpses (>30 days) holding
**2 SOL combined**. A typical decay: one validator went delinquent 2025-12-02 holding 731 SOL;
delegators held on for ~6 weeks, drained it to 0 on 2026-01-15; since then the corpse oscillates
0↔2 SOL of dust. The populations barely overlap — the biggest stakes are the newest outages, the
longest durations are worthless nodes.

### 2.5 Block production and skip rate

For each identity with leader slots, the cluster reports `leader_slots` (slots assigned) and
`blocks_produced`. The platform computes:

- `missed_slots = leader_slots − blocks_produced`
- `skip_rate_percent = missed_slots / leader_slots × 100` (0.0 when no leader slots)
- `performance_category` from the skip rate:

| Category | Skip rate |
|---|---|
| `Perfect` | 0% |
| `Excellent` | 0–1% |
| `Good` | 1–3% |
| `Average` | 3–5% |
| `Concerning` | 5–10% |
| `Poor` | 10–25% |
| `Critical` | 25–100% |
| `Offline` | 100% |
| `Insufficient` | fewer than 10 leader slots — not enough data, any rate |

Boundary values belong to the worse category (exactly 1.0% → `Good`, 3.0% → `Average`, 5.0% →
`Concerning`, 10.0% → `Poor`, 25.0% → `Critical`); `Excellent` is strictly between 0 and 1%.
Categories are current-epoch measurements; they reset each epoch as production counts restart.

**Identity-rotation attribution (important corner).** Skip metrics are aggregated across **all
identities a validator has ever used**. Because the leader schedule is computed an epoch ahead and
keyed by identity, a validator that rotates identity mid-epoch keeps its slots on the **old**
identity for up to an epoch. If the old node is shut down, those slots are skipped, and the
validator legitimately shows `skip_rate_percent: 100`, `blocks_produced: 0` **while
`is_delinquent: false`** (the new identity votes normally). This combination is correct, not
contradictory: liveness and block production are measured on different keys during a rotation
window. It self-resolves at the next epoch boundary when the schedule picks up the new identity.

### 2.6 Commission

The percentage of inflation rewards the operator keeps (0–100). `commission_change` events catch
both ordinary repricing and **commission rugs** — brief spikes to 100% timed around reward
distribution to confiscate delegator rewards.

### 2.7 Stake pools — how the platform measures them

The platform does **not** trust pool self-reporting. Each of the 61 tracked pools is identified by
its **staker authority address**; the pools daemon enumerates all Stake-program accounts whose
staker authority matches (on-chain `memcmp` at the staker field) and aggregates them by the
validator each account delegates to. Pool figures are therefore **measured on-chain delegation**,
per pool per validator, in lamports. A validator "in" a pool means: at least one stake account
under that pool's authority currently delegates to that validator's vote account.

Tracked pools (61, by internal name): `STKE, aerosol, binance, binance_2, binance_3, blazestake,
bonk, bybit, daopool, definity, double_zero, drift, dynosol, edgevana, eversol,
figment, figment_2, figment_3, figment_4, firedancer_delegation, forward_industries,
forward_industries_2, foundation, ftx, galaxy, galaxy_2, galaxy_3, helius, jag_pool, jito, jpool,
juicy, jupiter, jupiter_2, kiln, kiln_2, kraken, kraken_2, kraken_3, kraken_4, layer33, lido,
marginfi, marinade, marinade_native, marinade_native_2, marinade_select, okx, p2p, picosol,
sanctum, sanctum_2, shark_pool, shinobi, socean, solayer, starpool, twinstake, twinstake_2, upbit,
vault`. The set can grow; `/api/pools` is the authoritative live list.

**Coverage note:** 23 of these pools (binance_3, figment×4, forward_industries×2, galaxy×3,
jupiter_2, kiln×2, kraken×4, marinade_select, okx, p2p, twinstake×2, upbit) were added on
2026-08-13 (epoch ~1011). They have **no history before that point** — absence of earlier rows for
them is "not yet tracked", not "not in any pool". Their first membership events are discovery
artifacts (the whole existing delegation set appears at once), not real delegation activity.

Pool `name` values are these lowercase internal identifiers — filters on `pool_name` are exact and
case-sensitive (`marinade`, not `Marinade`).

### 2.8 Geolocation

Each validator's gossip IP is resolved to city, country, coordinates, ASN, and ASN organization
(`ip_metadata`, maintained by the geolocation daemon). This enables the geographic and
infrastructure analyses: stake-by-country, datacenter colocation clusters, and physical migration
tracking. IPs without metadata are excluded from map aggregations and counted in
`meta.excluded.missing_geo` — the API never invents an "Unknown" bucket.

### 2.9 Marinade institutional ("Select") bonds

Marinade's institutional program requires validators to post **collateral bonds**: stake accounts
whose withdrawer is a program-controlled PDA. The platform reads this on-chain state directly
(program `vBoNdEvzMrSai7is21XgVYik65mqtaKXuSdMBJ1xkW4`):

- **Bond** — collateral posted by a validator, keyed by `bond_pubkey`, joined to the rest of the
  dataset by `vote_account`. `owned_lamports` is total bonded SOL (validated to the lamport
  against the official Marinade CLI's `amountOwned`).
- **Settlement** — a claim against the bond after a protected event (the enforcement mechanism).
  Rising settlement activity on a bond is the clearest trouble signal for that operator.
- **Withdraw request** — an exit ticket. Collateral unlocks at `claimable_epoch` (request epoch +
  program lockup, currently 1 epoch). An open withdraw request means the operator is leaving or
  reducing commitment. On-chain "withdraw ALL" is a `u64::MAX` sentinel, surfaced as
  `requested_all: true`.

The daemon snapshots the entire bond set and persists **only when something changed** (content
digest), so the bond history is a complete, change-only archive.

---

## 3. Data provenance and coverage

All counts verified against production on **2026-07-05, epoch 997**. Coverage grows continuously;
the start boundaries are fixed.

| Stream | Source | Coverage starts | Size (2026-07-05) |
|---|---|---|---|
| Validator state + event log | validator daemon (polls gossip + vote accounts, ~minutes cadence) | 2025-10-10, epoch 862 | 4,071,695 events; 733 live validators |
| Epoch boundaries | validator daemon, epoch-boundary detection | epoch 862 | 136 epochs (862–997) |
| IP changes / movements | event log (`ip_change`) | epoch 862 | 110,398 events |
| Delinquency transitions | event log (`delinquency_change`) | epoch 862 | 55,588 events |
| Pool membership | pools daemon | 2025-10-23, epoch 868 | 42,587 events |
| Per-validator pool stake (unified series) | `validator_pool_stake_change` | epoch 868 (backfilled 868–944; live 945+) | 356,790 events |
| Legacy per-validator pool stake (integer SOL) | `validator_pool_avg_stake` | epochs 868–945 only (frozen) | 265,140 events |
| Pool-level epoch snapshots (5 types) | pools daemon, epoch flush | epoch 945; **gap: epochs 1008–1010** (RPC outage 2026-07-28→08-03) | ~1,800 rows per type |
| Pool×validator epoch snapshots (4 types) | pools daemon, epoch flush | epoch 945; **gap: epochs 1008–1010** | ~206,000 rows per type |
| Skip rate / block production | validator daemon (block production API) | current-epoch metric on state; category transitions in event log | 733 current rows |
| Geolocation | geolocation daemon | continuous | joined onto state and events |
| Marinade bonds | marinade-bonds daemon (30-min poll, change-only persist) | 2026-06-26, epoch 993 | 70 bonds, 122 change-points |
| AI epoch summaries | summary daemon | **none — table is empty** | `/api/summary` returns 404 (`raw=true` mode works; it reads the event log directly) |

Two structural facts about validator coverage:

- **Departed validators** (gone from the cluster) are excluded from `/api/validators` listings but
  remain fully retrievable by direct lookup (`/api/validators/:pubkey`) and keep their entire
  event history. The 733 count is live validators; history covers everyone ever observed.
- **Identity rotations** do not fragment a validator's record: skip metrics aggregate across all
  identities and `/api/validators/:pubkey` resolves any historical identity. One exception:
  `/api/history?validator=` matches the **current** identity only — resolve the current
  `identity_pubkey` via the detail endpoint first, then query history with it (pitfall 24).

---

## 4. Global API conventions

### 4.1 Request validation

Every endpoint has a strict parameter allowlist. Any unknown parameter → **400** with a message
listing the supported parameters. A parameter that fails to parse (e.g. `page=abc`) → 400.
Misspelled parameters therefore fail loudly rather than being silently ignored, with two caveats:
the endpoints that take no query parameters (`/api/health`, `/api/epoch`,
`/api/validators/:pubkey`) ignore query strings entirely, and on `/api/pools` the three
`validators_count_*` parameters are accepted but not applied (§5.8).

### 4.2 Error envelope

JSON endpoints return errors as:

```json
{ "error": "bad_request", "message": "…", "timestamp": "2026-07-05T12:00:00Z" }
```

`error` ∈ `bad_request` (400), `not_found` (404), `internal_error` / `database_error` /
`not_ready` (500). Exception: `/api/summary/md` returns plain-text error bodies for 404/500.
Any 429 or HTML error page originates from the CDN in front of the API, not from the API itself.

### 4.3 Pagination

`/api/validators`, `/api/history`, `/api/history/batches`, and all `/api/map/*` endpoints use:

```json
{ "data": [ … ],
  "pagination": { "page": 1, "page_size": 1000, "total_items": 733,
                  "total_pages": 1, "has_next": false, "has_previous": false } }
```

- `page` is 1-based (0 is coerced to 1). Default `page_size` = **1000**, maximum = **5000**
  (values above are silently clamped, verified live). Map endpoints additionally hard-cap at 5000.
- **Cheap counting:** `total_items` is the exact count of rows matching the filters. Request
  `page_size=1` and read `pagination.total_items` to count without transferring data. Caveat: on
  the three `/api/map/*` endpoints an out-of-range page reports `total_items: 0` — count on
  `page=1` only.

Not paginated: `/api/epochs` and `/api/pools` return bare arrays; `/api/validators/:pubkey`,
`/api/epoch`, `/api/summary` return single objects; marinade endpoints use `limit` + cursor
paging (§5.14–5.17).

### 4.4 Units — three regimes, read carefully

1. **Lamports as JSON numbers (i64)** — validator `stake`, event values for `stake_change` and
   `validator_pool_stake_change`, pool summary `*_lamports`, map `stake_lamports`.
   1 SOL = 10⁹ lamports. (The legacy `validator_pool_min/avg/max_stake` events are integer SOL,
   not lamports — §6.)
2. **SOL** — `stake_sol` on validators is **integer-truncated** lamports/10⁹. Pool `*_sol`
   fields (`/api/pools` and detail `pools[]`) are f64 **rounded to whole SOL**; map `*_sol`
   fields are fractional f64. Use `*_lamports` for any arithmetic.
3. **Lamports as JSON strings** — the 9 pool/pool×validator epoch-snapshot event types and **all
   Marinade bond amounts** are serialized as strings because network-scale lamport totals exceed
   JavaScript's 2⁵³ safe-integer range. Parse with BigInt/arbitrary precision.

### 4.5 Datetimes

All timestamps are ISO-8601 / RFC-3339 UTC. Time-filter parameters (`since`, `until`) must include
a timezone (`2026-04-01T00:00:00Z`).

### 4.6 Filter matching semantics

Two match modes appear throughout; they are per-field, not configurable:

- **Exact**: `pool_name`, `pool_authority`, `asn`, `version`, `feature_set`,
  `performance_category`, `country_code`, `old_value`, `new_value`, all numeric filters.
- **Case-insensitive substring (ILIKE)**: `name` (on `/api/validators`; on `/api/pools` `name`
  is exact), `website`, `description`, `city`, `country`, `asn_organization`.

`country` matches the full English name ("Germany"); `country_code` (map endpoints) matches ISO
codes ("DE").

---

## 5. Endpoint reference

### 5.1 GET /api/health

No parameters. Returns `{ "status": "ok", "database": "healthy" | "unhealthy", "timestamp": … }`.
Always 200; a database outage shows as `database: "unhealthy"`.

### 5.2 GET /api/epoch

Current cluster clock. No parameters.

```json
{ "epoch": 997, "slot": 431048710, "percent": 79.79, "absolute_slot": 431048710,
  "slots_in_epoch": 432000, "slot_index": 344710, "slots_remaining": 87290,
  "estimated_time_remaining_seconds": 34916 }
```

`percent` is epoch progress. `estimated_time_remaining_seconds` assumes 400 ms slots. Use this
endpoint first when a question involves "now", "current epoch", or scheduling relative to the
epoch boundary.

### 5.3 GET /api/epochs

Measured epoch boundaries and durations, epochs 862+. Returns a bare ascending array.

Parameters: `epoch` (exact; cannot combine with range), `epoch_gte`, `epoch_lte` (range;
`lte ≥ gte`). All must be ≥ 0. No parameters → all rows.

```json
[{ "epoch": 968, "first_slot": …, "start_time_source_slot": …,
   "start_time": "2026-05-14T…Z", "end_time": "2026-05-16T…Z",
   "duration_seconds": 172811, "slots_in_epoch": 432000 }]
```

`end_time`/`duration_seconds` are derived from the next epoch's start and are `null` when the next
stored epoch is not contiguous (and always for the newest epoch). Use for: converting timestamps ↔
epochs, epoch-duration trends (slot-time health), aligning event timelines to epoch grid.

### 5.4 GET /api/validators

The current validator set — one row per **live** (non-departed) validator, full state. Paginated.

**Filters** (combined with AND):

| Group | Parameters | Semantics |
|---|---|---|
| Stake | `stake`, `stake_gt/gte/lt/lte` (lamports); `stake_sol_min`, `stake_sol_max` (whole SOL, converted ×10⁹) | vote-account activated stake |
| Commission | `commission`, `commission_gt/gte/lt/lte` | percent |
| Liveness | `is_delinquent` | boolean |
| Software | `version` (exact), `feature_set` (exact) | gossip-reported |
| Performance | `skip_rate_gt/gte/lt/lte` (percent), `leader_slots_*`, `blocks_produced_*`, `missed_slots_*` (i64), `performance_category` (exact, §2.5 values), `has_skip_rate` | current epoch, rotation-aggregated |
| Pools | `pool_name` (exact internal name), `in_any_pool` (bool) | membership derived from latest membership event |
| Identity/meta | `name`, `website`, `description` (ILIKE substring) | on-chain validator info |
| Location | `city`, `country` (ILIKE substring), `asn` (exact `AS…`), `asn_organization` (ILIKE), `has_geolocation` | via IP metadata |
| Network | `has_rpc`, `gossip_port`, `tpu_port`, `tpu_quic_port`, `tpu_forwards_port`, `tpu_forwards_quic_port`, `tpu_vote_port`, `tvu_port`, `rpc_port` | gossip endpoints |

**Ordering**: `order_by` ∈ `stake`, `stake_sol` (alias of stake), `commission`, `version`,
`is_delinquent`, `has_geolocation`, `has_rpc`, `first_seen` (default), `skip_rate`,
`skip_rate_percent`, `leader_slots`, `blocks_produced`, `missed_slots`, `performance_category`;
`order_direction` ∈ `asc`, `desc` (default `desc`). NULLS always sort last.

**Response row** (all fields nullable unless stated):

```json
{ "identity_pubkey": "…", "vote_pubkey": "…", "first_seen": "…",     // never null
  "commission": 5, "version": "2.3.6", "ip": "…",
  "gossip_port": 8001, "tpu_port": …, "tpu_quic_port": …, "tpu_forwards_port": …,
  "tpu_forwards_quic_port": …, "tpu_vote_port": …, "tvu_port": …, "rpc_port": …,
  "rpc_addr": "…", "feature_set": "…",
  "stake": 123456789012345, "stake_sol": 123456,                      // lamports; truncated integer SOL
  "is_delinquent": false,
  "geolocation": { "location": "Frankfurt, Germany", "city": "…", "country": "…",
                   "latitude": 50.1, "longitude": 8.6, "asn": "AS24940",
                   "asn_organization": "Hetzner Online GmbH" },
  "name": "…", "website": "…", "description": "…",
  "skip_rate_percent": 0.42, "leader_slots": 236, "blocks_produced": 235,
  "missed_slots": 1, "performance_category": "Excellent", "skip_rate_updated_at": "…",
  "pools": ["<authority_address>", …] }                               // authorities, NOT names; may be []
```

Corners: `pools[]` on the **list** contains pool authority addresses only — resolve names/stakes
via the detail endpoint or `/api/pools`. `first_seen` is when the platform first observed the
validator (started 2025-10-10 — genesis-era validators show that date, not their true age).
`geolocation` is null when the IP has no city in metadata even if `has_geolocation=true` matched.

### 5.5 GET /api/validators/:pubkey

Full state of one validator. The path accepts the **current identity pubkey, the vote pubkey, or
any historical identity pubkey** — all resolve to the same validator. Pubkey is format-validated
(32–44 base58 chars) → 400 on malformed, 404 when unknown.

Response: identical to a list row **except** `pools` is fully resolved:

```json
"pools": [{ "authority_address": "…", "name": "marinade",
            "active_stake_lamports": 757279000000000, "active_stake_sol": 757279.0,
            "activating_stake_lamports": 0, "activating_stake_sol": 0.0,
            "deactivating_stake_lamports": 0, "deactivating_stake_sol": 0.0 }]
```

Unlike the list, this endpoint **does return departed validators** — use it to inspect validators
that have left the cluster.

### 5.6 GET /api/history

The event log — the platform's core historical record. Paginated, default order
`event_time desc`.

**Parameters**: `event_type` (one of the 38 filterable types, §6; case-sensitive, invalid → 400
listing all — note four additional realtime types appear in responses but are not filterable),
`validator` (identity pubkey, format-validated), `pool_name` / `pool_authority` (exact),
`since` / `until` (event_time bounds), `epoch` / `epoch_gt/gte/lt/lte`, `old_value` / `new_value`
(**exact string match** against the stored value — e.g. `new_value=true`, `new_value=100`),
`batch_id` / `id` (UUIDs — validate client-side: a malformed `id` returns 400 but a malformed
`batch_id` returns 500), `order_by` ∈ `event_time` (default) | `event_type` | `epoch` |
`validator_identity`, `order_direction`, `page`, `page_size`.

**Implicit filters always applied** (you cannot disable them):
1. Rows with `old_value IS NULL` are hidden **except** for the 13 snapshot-semantics types
   (the `validator_pool_min/avg/max_stake` trio, `validator_pool_stake_change`, and the 9
   pool/pool×validator epoch-snapshot types) where null `old_value` is by design.
2. `skip_rate_category_change` rows touching `Insufficient` are hidden (data-availability noise,
   not performance changes).
3. Migration artifacts are hidden.

**Response row**:

```json
{ "id": "uuid", "validator_identity": "…",       // ABSENT on the 5 pool-level snapshot types
  "event_type": "commission_change", "event_time": "…", "epoch": 990,
  "old_value": 5, "new_value": 100,              // old_value ABSENT when null; typing per §6
  "metadata": null, "batch_id": "uuid-or-null",
  "pool_name": "…", "pool_authority": "…" }      // present only on pool-scoped events
```

For `ip_change` events, `metadata` is rewritten at read time to
`{ "old_geolocation": {…}|null, "new_geolocation": {…}|null }` — an IP migration therefore carries
its own before/after geography without a second query.

### 5.7 GET /api/history/batches

Infrastructure changes grouped into atomic **batches** — when a validator changes IP, ports, and
version in one observation cycle, those events share a `batch_id`. One batch = one validator's
simultaneous change set. Paginated.

Parameters: `validator` (exact identity; **not** format-validated here), `since`/`until`,
`epoch` + ranges (applied to the batch's MAX(epoch)), `batch_id`, `order_by` ∈ `batch_time`
(default) | `epoch` | `event_count` | `identity_pubkey` (alias `validator`), `order_direction`
(case-insensitive here), `page`, `page_size`.

Response row: `batch_id`, `validator_identity`, `batch_time` (earliest event in batch), `epoch`
(max), `event_count`, `geolocation_context` (`{old_location, new_location}` when the batch
contains an `ip_change`, else null), `events[]` (full event rows, ascending time). Use batches to
answer "what happened during this migration?" as one object instead of correlating raw events.

### 5.8 GET /api/pools

Current statistics for all 61 tracked pools. Bare array, not paginated.

Parameters: `name` (exact), `authority_address` (exact), `order_by` ∈ `total_accounts`,
`pool_validators_count`, `active_accounts`, `active_accounts_stake_lamports`,
`activating_accounts`, `activating_accounts_stake_lamports`, `deactivating_accounts`,
`deactivating_accounts_stake_lamports`, `total_accounts_stake_lamports`, `created_at`,
`updated_at` (default sort: active stake desc), `order_direction`, `limit`. The parameters
`validators_count_eq`/`_gte`/`_lte` are accepted but **not applied** — do not use them; filter
client-side.

Response row: `authority_address`, `name`, `total_accounts`, `pool_validators_count`, the
account counts `active_accounts` / `activating_accounts` / `deactivating_accounts`, per
stake-state `*_stake_lamports` (i64) + `*_stake_sol` (f64, rounded to whole SOL), plus
`created_at`, `updated_at`. Accounts are on-chain stake accounts under the pool authority (§2.7);
`updated_at` tells you data freshness. The activating/deactivating buckets are the pool's
committed inflow/outflow for the next epoch boundary.

### 5.8b GET /api/pools/validators

The live pool×validator delegation grid in one call (available from v0.9.6). Bare array, not
paginated — a few thousand rows across all pools. This is the one-shot answer to "who is in pool
X and with how much" and to building any per-validator pool split client-side; prefer it over
fanning out `/api/validators/:pubkey` calls.

Parameters: `pool_name` (exact internal name, §2.7), `authority_address` (exact). Both optional;
omit both for the full grid.

Response row: `pool_name`, `pool_authority`, `identity_pubkey`, `vote_pubkey`,
`active_stake_lamports`/`_sol`, `activating_stake_lamports`/`_sol`,
`deactivating_stake_lamports`/`_sol`, `updated_at`. Rows exist only for **current** members —
a validator a pool has fully exited has no row (use §5.6 membership events for history). Rows
are eventually consistent within one pools-daemon cycle: a validator that just left a pool may
persist for a few seconds with pre-cycle stake, identifiable by an older `updated_at`. Sorted
by pool name, then active stake descending. Note the shape difference from §5.4: the validators
*list* endpoint carries `pools` as bare authority-address strings (membership only, no stake);
per-pool stake lives here and on the validator detail endpoint (§5.5).

### 5.9 GET /api/summary and 5.10 GET /api/summary/md

AI-generated narrative per epoch, from the `epoch_summaries` table.
**Current production state: the table is empty — both endpoints return 404** ("No summaries
available yet"). Do not rely on them for data retrieval.

Functional regardless of that table: `/api/summary?raw=true&max_chars=N` compacts the requested
epoch's raw events (default: latest epoch with events) into a text digest —
`{ epoch, events_count, max_chars, actual_chars, compacted_data }`. This is a useful one-call
"what happened in epoch X" text feed for an LLM consumer.

Parameters: `epoch` (default latest), `raw` (bool), `max_chars` (default 50000, raw mode only).
`/api/summary/md` takes only `epoch` and returns `text/markdown`.

### 5.11 GET /api/map/distribution

Stake aggregated into geographic or network buckets, with stake-weighted centroids. Answers
"where is the stake?" — by country or by hosting provider — **now or as of any past epoch**.

Parameters:

| Param | Notes |
|---|---|
| `group_by` | **required**: `country` or `asn` |
| `epoch` | historical mode: reconstructs IP and stake per validator as of epoch N from the event log. Omit for current state. Must be ≥ 0 |
| `country` (ILIKE), `country_code` (exact), `asn` (exact), `asn_organization` (ILIKE) | geographic filters |
| `validator` | single validator (identity or vote pubkey) |
| `pool_name` / `pool_authority` | **scopes which stake counts as "pooled"** in the response fields; does NOT restrict the validator set unless `pool_only=true` |
| `pool_only` | `true` requires a pool filter and then restricts to members of that pool |
| `page`, `page_size` | pagination over buckets |

Bucket row: `geo_key` (country code or AS number), `geo_key_source` (`code`/`name`/`asn`),
`label`, `validators_count`, `stake_lamports`/`stake_sol`, `lat`/`lng` (stake-weighted centroid;
plain average when the bucket's stake is zero),
`pooled_stake_lamports/_sol`, `non_pooled_stake_lamports/_sol` (pooled is clamped per-validator to
never exceed total). Ordered by stake desc. Envelope carries `meta.excluded`
(`missing_geo`, `missing_stake`, `stake_clamped`) — validators dropped for missing data, so you
can state coverage honestly.

### 5.12 GET /api/map/movements

Physical validator migrations — every `ip_change` event in a time window with source and
destination geography, haversine distance, and current stake. Answers "who moved, where, how far?"

Parameters: `until` (default now) / `since` (default `until` − 7 days), max window **365 days**;
`epoch` + `epoch_gt|gte` / `epoch_lt|lte` (each pair mutually exclusive; **epoch filters are ANDed
inside the time window and never widen it** — an epoch filter alone still returns only the default
last 7 days; reaching older epochs requires explicit `since`/`until`, paged ≤ 365 days per
request); `validator` (identity or vote); `country`/`country_code`/`asn`/`asn_organization` —
matching **either endpoint** of the move (catches both departures from and arrivals to a place);
`pool_name`/`pool_authority` (**current** membership, not membership at event time);
`min_distance_km` ∈ [0, 20037.5]; `cross_country_only` (bool); `order_direction` (by event_time,
default desc); pagination.

Movement row:

```json
{ "validator_identity": "…", "vote_pubkey": "…", "event_time": "…", "epoch": 882,
  "from": { "ip": "…", "country_code": "GB", "country": "…", "city": "…",
            "lat": 51.5, "lng": -0.12, "asn": "…", "asn_organization": "…" },
  "to":   { …same shape… },
  "distance_km": 342.1,          // haversine; null when either side lacks coordinates
  "cross_country": true,
  "genesis": false,              // true = validator's first-ever IP; "from" is all null
  "stake_lamports": …, "stake_sol": … }   // CURRENT stake, not stake at event time; null if unknown
```

Corners: `min_distance_km` also silently drops all `genesis` rows and moves with unknown
coordinates (null fails the comparison) — query genesis events separately if needed.
`meta.excluded.missing_geo` counts rows where either endpoint lacks IP metadata; every genesis row
is counted by construction, so the counter overstates true geo gaps (the rows themselves stay in
`data`). ~1,400 movements occur per 7-day window; the full archive covers 110k+ IP changes since
epoch 862, reachable only by paging explicit `since`/`until` windows backward.

### 5.13 GET /api/map/colocations

Datacenter concentration — clusters of validators sharing physical hosting. Answers "how much
stake sits in one building / one provider-city?"

Parameters: `precision` = `latlng` (default; coordinates rounded to 4 decimals ≈ 11 m — same
building) or `asn_city` (provider + city — metro-level); `min_validators` (default 2, range
[1, 10000]); geo filters (`country`, `country_code`, `asn`, `asn_organization`) applied **before**
clustering; `pool_name`/`pool_authority` (current membership); `order_by` = `stake` (default) or
`validators`; pagination. Current state only (no epoch mode).

Cluster row: `label`, `precision`, `lat`/`lng` (plain average of members), `city`, `country`,
`country_code`, `asn`, `asn_organization` (most-common value per cluster), `validators_count`,
`stake_lamports`/`stake_sol`, `stake_share_of_total`, `validators[]` (up to 500 members, stake
desc, each `{identity_pubkey, vote_pubkey, stake_lamports, stake_sol}`).

**Denominator warning:** `stake_share_of_total` divides by the total stake of the **filtered**
set, reported as `meta.total_stake_lamports`. Unfiltered, that is network share; with
`country_code=DE` it is share of German stake. For network-wide shares, query unfiltered or
divide by an unfiltered total yourself. Two edge cases: when `data` comes back empty the reported
total silently falls back to the unfiltered network total, and in `asn_city` mode validators
lacking an ASN or city are dropped without being counted in `meta.excluded`.

### 5.14 GET /api/marinade-bonds

Latest complete bond set. Supports conditional polling.

Parameters: `since` (i64 version — the snapshot's Unix timestamp; equal to latest → **304 Not
Modified**, empty body), `at` (i64 — point-in-time: full bond set at the greatest version ≤ `at`;
404 if none). `If-None-Match` ETags also honored; every 200 carries `ETag: "<version>"`.

```json
{ "version": 1783253530,        // change cursor; echo as ?since= next poll
  "epoch": 997,
  "bonds": [                    // ordered by vote_account
    { "bond_pubkey": "…", "vote_account": "…", "authority": "…",
      "owned_lamports": "50000000000",     // ALL amounts are strings (BigInt)
      "active_lamports": "49997717120",
      "stake_accounts": 1,
      "withdraw": {                        // present only when an exit ticket is open
        "account": "…", "epoch": 995, "requested_all": true,
        "requested_lamports": "…",         // omitted when requested_all
        "withdrawn_lamports": "0", "claimable_epoch": 996 },
      "settlement": {                      // present only when the bond has settlements
        "count": 2, "funded_lamports": "…", "claimed_lamports": "…" } } ] }
```

Empty database returns `{version: 0, epoch: 0, bonds: []}` with 200, never 404. Field meanings:
§2.9. `owned_lamports` = total collateral; `active_lamports` = the delegated portion (excludes
rent reserve).

### 5.15 GET /api/marinade-bonds/snapshots

The change timeline — every moment the **stored state** changed. Parameter: `limit` (default 200,
max 2000; most recent N). Response: `{ version, count, snapshots: [ { version, event_time, epoch,
bond_count } ] }`, newest first. A row is written whenever the stored state changed — bond fields,
program config, funding-account composition, **or the epoch stamp** — so an epoch rollover
produces a row even when no bond moved, and a change-point can legitimately yield zero `/events`.
Do not count snapshot rows as bond activity; count typed events instead. The archive begins
2026-06-26 (epoch 993).

### 5.16 GET /api/marinade-bonds/events

Typed change feed, computed by diffing consecutive snapshots. The primary endpoint for "what
happened to bonds?"

Parameters: `since` (forward tail: **oldest** N change-points strictly after the cursor — a
catching-up client never skips; re-poll with `since = page.newest_version`), `before` (backfill:
**newest** N strictly before the cursor; page while `page.has_older`), neither = newest N.
Setting **both** cursors switches to backward (newest-first) semantics and can permanently skip
events — never combine them when forward-tailing. `epoch_gte`/`epoch_lte`; `vote_account`,
`bond_pubkey` (validated pubkeys); `type` (comma-separated event types; unknown values are
**not** rejected — a typo returns an empty 200, not a 400); `limit` (change-points scanned,
default 200, max 2000).

Event types:

| type | Meaning | Key payload |
|---|---|---|
| `bond_appeared` | new bond in the set | `owned_to`, `owned_delta` |
| `bond_closed` | bond left the set | `owned_from`, `owned_delta` (negative) |
| `funding_changed` | collateral amount changed | `owned_from`, `owned_to`, `owned_delta` (signed) |
| `withdraw_opened` | exit ticket created | `withdraw_account` |
| `withdraw_closed` | exit ticket resolved/cancelled | — |
| `withdraw_progressed` | partial withdrawal executed | `withdrawn_lamports`, `withdrawn_delta` |
| `settlement_funded` | claim funded against the bond | `settlement_funded_lamports`, `_delta` |
| `settlement_claimed` | funded claim collected | `settlement_claimed_lamports`, `_delta` |

All amounts and deltas are signed strings. One transition can emit several events per bond.
Response: `{ latest_version, count, events: [ { type, version, event_time, epoch, bond_pubkey,
vote_account, …payload } ], page: { newest_version, oldest_version, has_older, has_newer } }`,
newest first.

Resolution caveat: the feed is a diff of stored snapshots — a state that opens **and** closes
between two consecutive stored snapshots (< ~30 min apart at minimum) is invisible.

### 5.17 GET /api/marinade-bonds/:bond_pubkey/history

One bond's value series, oldest first. Response envelope:
`{ bond_pubkey, vote_account, count, series: [ <point> ] }`. Parameter: `limit` (default 200,
max 2000) — it bounds the underlying **snapshots scanned** (newest N), not the points returned,
so `count < limit` does not imply the complete history was fetched. 404 when the bond has no
rows.

Series point: `{ version, event_time, epoch, owned_lamports, active_lamports, stake_accounts,
settlement_funded_lamports?, settlement_claimed_lamports?, withdraw_account? }` (optional fields
omitted when the bond has no settlements / no open withdraw). Consecutive identical points are
collapsed, so each interior point is a real change to **this** bond; the first point is the window
floor, not necessarily a change.

---

## 6. Event-type catalog

All 38 **filterable** `event_type` values on `/api/history`, with value typing of
`old_value`/`new_value` and coverage, plus 4 realtime delta types that appear in responses but
are rejected as filter values (end of this section). "Delta" events carry old→new; "snapshot"
events carry an absolute per-epoch value with `old_value` intentionally null (the prior epoch's
row holds the previous value).

### Per-validator delta events (coverage: epoch 862+)

| event_type | Values | Notes |
|---|---|---|
| `commission_change` | number (percent) | watch `new_value=100` for rug patterns |
| `version_change` | string | software upgrades; adoption waves |
| `ip_change` | string (IP) | `metadata` carries old/new geolocation; feeds `/api/map/movements` |
| `gossip_port_change`, `tpu_port_change`, `tpu_quic_port_change`, `tpu_forwards_port_change`, `tpu_forwards_quic_port_change`, `tpu_vote_port_change`, `tvu_port_change`, `rpc_port_change` | number | network reconfigurations |
| `rpc_addr_change` | string | |
| `feature_set_change` | string | |
| `stake_change` | number (lamports) | vote-account activated stake transitions |
| `delinquency_change` | boolean | liveness flips; `new_value=true` = went down, `false` = recovered |
| `identity_change` | string (pubkey) | identity rotation; see §2.5 for skip-rate interaction |
| `skip_rate_category_change` | string (§2.5 categories) | `Insufficient` transitions are server-filtered |
| `name_change`, `website_change`, `description_change` | string | validator-info edits |
| `city_change`, `country_change`, `asn_change`, `asn_organization_change` | string | derived geo changes (usually accompany `ip_change`) |

### Per-validator-in-pool events (carry `pool_name`/`pool_authority`)

| event_type | Values | Coverage | Notes |
|---|---|---|---|
| `validator_pool_membership_change` | string `"true"`/`"false"` | 868+ | joined/left pool |
| `validator_pool_stake_change` | number (lamports); snapshot semantics | **868+, the unified series** | 868–944 backfilled from the legacy average series (integer-SOL precision ×10⁹, `old_value` null, `metadata.backfill_source` set); 945+ live rows. One continuous per-validator-per-pool stake series — sort by epoch asc, treat null `old_value` as snapshot start |
| `validator_pool_min_stake`, `validator_pool_avg_stake`, `validator_pool_max_stake` | number — **integer SOL**, not lamports | 868–945, frozen | legacy; superseded by `validator_pool_stake_change`; multiply by 10⁹ to compare |

### Pool-level epoch snapshots (coverage: 945+; **no `validator_identity` field**; values are numeric **strings**)

`pool_total_accounts_stake_lamports_change`, `pool_active_accounts_stake_lamports_change`,
`pool_activating_accounts_stake_lamports_change`,
`pool_deactivating_accounts_stake_lamports_change`, `pool_validators_count_change` —
one row per pool per epoch, emitted at epoch flush. The per-epoch history of what `/api/pools`
shows live. The four `*_stake_lamports_change` values are lamports;
`pool_validators_count_change` is a validator **count** serialized as a string — never divide it
by 10⁹.

### Pool×validator epoch snapshots (coverage: 945+; all values numeric **strings**)

`pool_validator_active_stake_lamports_change`, `pool_validator_activating_stake_lamports_change`,
`pool_validator_deactivating_stake_lamports_change`,
`pool_validator_last_epoch_credits_cumulative_change` — one row per pool per validator per epoch.
The three stake values are lamports; the credits value is a **cumulative vote-credit count**
(also a string — never divide by 10⁹). Credits rows are omitted for epochs where the value was
unavailable, so a hole in the credits series is not a pool exit.

**Reconstruction rules for these footprint series** (violating them corrupts historical math):
1. All three stake streams **freeze on pool exit** — no terminal zero row is emitted. Bound any
   carry-forward by membership: stop at the `validator_pool_membership_change → "false"` epoch and
   treat later epochs as 0.
2. The activating/deactivating streams are **not idempotent** — duplicate rows can exist for one
   (validator, pool, epoch). Take the latest row per epoch; never sum duplicates.
3. Coverage starts at epoch 945; earlier epochs have no footprint data (use
   `validator_pool_stake_change` back to 868 for active stake).
4. **Epochs 1008–1010 are a hole in ALL pool epoch streams** (pool-level, pool×validator, and
   `validator_pool_stake_change`) — an RPC schema change broke pool fetching 2026-07-28→08-03
   and those epoch flushes never ran. Absence there is missing data, never a zero; interpolate
   or say "no data", and expect membership changes from that window to be attributed to epoch
   1011 (detected late).

### Realtime threshold deltas (in responses, but NOT filterable via `event_type`)

Four additional event types exist in the log and can appear in unfiltered, validator-scoped, or
pool-scoped history responses, yet are **rejected as `event_type` filter values** (400):
`pool_validator_activating_stake_change`, `pool_validator_deactivating_stake_change`
(per validator per pool) and `pool_activating_stake_change`, `pool_deactivating_stake_change`
(pool level, no `validator_identity`). They fire whenever an activating/deactivating stake bucket
moves by more than a configured threshold — an intra-epoch delta stream on an irregular grid,
with both `old_value` and `new_value` set (lamport values as strings) and
`metadata.delta_lamports` / `metadata.delta_sol` fields. Do **not** confuse them with the epoch-snapshot types above
(same names plus `_lamports`): different grid, different semantics. Ignore them for per-epoch
reconstruction; treat them only as "stake is moving right now" signals.

---

## 7. Answer recipes

Concrete compositions for representative question classes. `$BASE = https://get.art3mis.cloud`.

### 7.1 "How many validators went delinquent in epoch X?"

```
GET $BASE/api/history?event_type=delinquency_change&epoch=X&new_value=true&page_size=1
→ pagination.total_items
```

Verified: epoch 990 → 22 down-flips, and `new_value=false` → 28 recoveries. Note `total_items`
counts **events**; a validator that flapped twice in the epoch counts twice. For unique
validators, fetch the rows and deduplicate on `validator_identity`.

### 7.2 "Which validators are delinquent right now, and how much stake is offline?"

```
GET $BASE/api/validators?is_delinquent=true&order_by=stake&order_direction=desc&page_size=5000
```

Sum `stake` client-side. Cross-check any individual case against `delinquency_change` history for
duration ("down since when" = event_time of its latest `new_value=true` flip).

### 7.3 "Full dossier on validator V"

1. `GET /api/validators/V` — current state (works with identity, vote, or old identity; also for
   departed validators).
2. `GET /api/history?validator=<identity>&page_size=5000` — complete change history.
3. `GET /api/history/batches?validator=<identity>` — grouped infrastructure migrations.
4. `GET /api/map/movements?validator=<identity>&since=max(first_seen, now−365d)` — physical
   relocation track (the endpoint rejects windows over 365 days; page older windows explicitly).
5. If Marinade Select: `GET /api/marinade-bonds/events?vote_account=<vote>` — bond activity.

### 7.4 "How concentrated is stake geographically / by provider?"

```
GET /api/map/distribution?group_by=country&page_size=5000    # or group_by=asn
```

Compute shares and HHI client-side from `stake_sol` over the returned buckets (the endpoint
provides no share field); report `meta.excluded.missing_geo` as coverage caveat. For "as of epoch
N" add `epoch=N` — the state is reconstructed from the event log.

### 7.5 "Which validators moved across borders recently, and how far?"

```
GET /api/map/movements?since=…&cross_country_only=true&page_size=5000
```

Sort by `distance_km` client-side. To exclude same-datacenter IP flips without losing new
validators, filter `distance_km >= threshold` client-side rather than using `min_distance_km`
(which silently drops genesis and unknown-coordinate rows).

### 7.6 "How much stake shares single buildings?" (colocation risk)

```
GET /api/map/colocations?precision=latlng&min_validators=2&page_size=100
```

Unfiltered, `stake_share_of_total` is network share. Escalate to `precision=asn_city` for
provider-metro concentration. The `validators[]` array names every co-located validator (cap 500).

### 7.7 "How did pool P behave over the last N epochs?"

Pool-level: one row per epoch from 945+ —

```
GET /api/history?event_type=pool_active_accounts_stake_lamports_change&pool_name=P&epoch_gte=…&order_by=epoch&order_direction=asc
```

(also `_activating_`/`_deactivating_`/`pool_validators_count_change`; values are lamport strings —
BigInt). Composition changes: `event_type=validator_pool_membership_change&pool_name=P` gives
joins (`new_value="true"`) and exits (`"false"`) per epoch.

### 7.8 "One validator's stake inside one pool over time"

```
GET /api/history?event_type=validator_pool_stake_change&validator=…&pool_name=P&order_by=epoch&order_direction=asc&page_size=5000
```

Continuous lamports series from epoch 868. Sort epoch asc; rows with absent `old_value` are
snapshot starts; carry values forward between epochs; apply the §6 reconstruction rules (stop the
carry-forward at pool exit).

### 7.9 "Software adoption: how fast did the cluster move to version X?"

Current distribution: page `GET /api/validators?page_size=5000` once, group by `version`
client-side. Adoption timeline: `GET /api/history?event_type=version_change&new_value=X&order_by=event_time&order_direction=asc`
— the event-time curve is the adoption wave; join `stake` from the validators list for
stake-weighted adoption.

### 7.10 "Who is performing badly this epoch?"

```
GET /api/validators?skip_rate_gte=10&leader_slots_gte=10&order_by=skip_rate&order_direction=desc
```

`leader_slots_gte=10` excludes statistically meaningless samples (mirrors the `Insufficient`
rule). Interpret 100% + non-delinquent as a possible identity-rotation window (§2.5) — check
`identity_change` events before concluding operational failure.

### 7.11 "Commission rug detection"

```
GET /api/history?event_type=commission_change&new_value=100&since=…
```

Then for each hit, look for the mirror drop (old_value=100) shortly after — the spike-and-revert
pair around an epoch boundary is the rug signature. Values are JSON numbers here.

### 7.12 "What is happening in Marinade Select?"

Live posture: `GET /api/marinade-bonds` — bonds with open `withdraw` = exiting operators; bonds
with `settlement` = enforcement history. Flows: `GET /api/marinade-bonds/events?type=funding_changed,settlement_funded,withdraw_opened`
— capital inflow/outflow and trouble events, cursor-paged. Single operator drilldown:
`/api/marinade-bonds/:bond_pubkey/history`. Join everything to validator context via
`vote_account` → `/api/validators/:vote_account`.

### 7.13 "Convert between epochs and dates"

`GET /api/epochs?epoch_gte=A&epoch_lte=B` — exact measured boundaries. For "what epoch was
2026-03-01?", fetch all epochs once and bisect on `start_time`. For epoch-duration health, plot
`duration_seconds` — shorter epochs = faster slots.

### 7.14 "Count anything cheaply"

Any paginated endpoint with filters + `page_size=1` → `pagination.total_items` is an exact
server-side count: events of a type in a range, validators matching a profile, movements in a
window, batches for a validator.

### 7.15 Token economy — querying the event log without drowning

The event log is not uniformly dense, and the skew is hostile: **one validator holds ~13% of all
events** (526k of 4.07M from a single identity — two instances sharing one identity key dueled in gossip,
flipping IP every 10–40 seconds; 519k events across epochs 921–960, 66k in epoch 947 alone).
Any validator can become such an emitter at any time. Discipline, in order:

1. **Count before fetching, always.** `page_size=1` → `total_items` for the exact filter set you
   intend to pull. Decide from the number; never page an unknown-size result.
2. **Budget the pull.** If the count exceeds what the answer needs (more than a few hundred
   rows), do not page raw events. Aggregate instead: per-type counts, per-epoch counts, per-day
   counts via `since`/`until` — each is one cheap request — and fetch at most the first and last
   page for concrete examples.
3. **Recognize the gossip-duel signature** and collapse it: near-identical counts on `ip_change`
   plus several `*_port_change` types, values oscillating A↔B at second-to-minute intervals.
   That is one root cause (two simultaneous instances), not N incidents. Report it as
   "N flips between A and B from t₀ to t₁ — dual-instance gossip duel"; never enumerate the
   events.
4. **There is no exclusion filter** (`validator!=X` does not exist). To analyze "everyone except
   the noise emitter", compute totals and subtract the emitter's counts, or query scoped
   populations (per pool, per epoch, per type).
5. `/api/history/batches` compresses each flip (~7 events) into one batch, but tens of thousands
   of batches is still unpayable — apply rule 1 there too.

Flag suspected dual-instance operation explicitly: it means the validator's infrastructure
identity (IP, ports, geo) is bistable, so its "current" IP/location is whichever instance won
gossip last — treat its geo fields as unstable while the duel runs.

---

## 8. Pitfalls and invariants

Consolidated list; each item has silently produced wrong answers for consumers that ignored it.

**Identity & lifecycle**
1. `/api/validators` excludes departed validators; `/api/validators/:pubkey` and `/api/history`
   include them. "All validators" questions must state which population.
2. `first_seen` is platform observation start, not validator age — floor is 2025-10-10.
3. During an identity rotation, 100% skip + `is_delinquent: false` is correct (§2.5); the window
   self-resolves at the next epoch boundary.

**Units & typing**
4. Validator `stake_sol` is truncated integer SOL; pool/map `*_sol` are f64. Prefer lamports for
   arithmetic.
5. Pool epoch-snapshot events and all Marinade amounts are lamport **strings** — BigInt, never
   float-parse. Per-validator series (`stake_change`, `validator_pool_stake_change`) are JSON
   numbers.
6. Legacy `validator_pool_avg_stake` is integer SOL; multiply by 10⁹ before comparing with the
   lamport series.
7. `old_value`/`new_value` filters are exact string matches against stored text: `new_value=true`
   (booleans), `new_value=100` (numbers) work; substrings and inequalities do not.

**History semantics**
8. Snapshot-type events have `old_value` absent by design; the previous epoch's row carries the
   prior value. Do not treat absence as a data bug or a zero.
9. Footprint streams freeze on pool exit (no terminal zero) and the activating/deactivating
   streams can carry duplicate rows per epoch — apply the §6 reconstruction rules.
10. Event `epoch` is the observation epoch. Sub-epoch ordering needs `event_time`.
11. The three implicit history filters (§5.6) are always on — the raw log contains more rows than
    the API exposes (initial-observation rows, `Insufficient` transitions, migration artifacts).
12. `/api/history` validates `validator` as base58; `/api/history/batches` does not (a typo there
    returns an empty set, not a 400).

**Coverage boundaries**
13. Event log: epoch 862+. Pool membership + unified pool stake: 868+. Pool/pool×validator epoch
    snapshots: 945+. Marinade archive: 2026-06-26 (epoch 993)+. Questions before these floors are
    unanswerable from this API — say so rather than extrapolating.
14. `/api/summary` (AI narratives) currently 404s — the table is empty. `raw=true` mode works.

**Map endpoints**
15. On `/api/map/distribution`, `pool_name` alone does **not** restrict the validator set — it
    only scopes the pooled/non-pooled split. Add `pool_only=true` to restrict membership.
16. `/api/map/colocations` `stake_share_of_total` is share of the **filtered** set
    (`meta.total_stake_lamports` is the denominator).
17. `min_distance_km` on movements drops genesis and unknown-coordinate rows (null distance fails
    the comparison).
18. Movement `stake_lamports` is the validator's **current** stake, not stake at move time.
19. Movements `meta.excluded.missing_geo` rows are counted but **kept** in `data` (with null geo);
    on distribution/colocations excluded rows are actually dropped. The movements counter
    includes every genesis row by construction (null from-side).

**Marinade**
20. Change-points are snapshot diffs — anything that opens and closes between two stored
    snapshots is invisible; `events` cursors track scanned change-points, not filtered events
    (an empty page does not mean "done" — follow `has_older`/`has_newer`).
21. `requested_all: true` withdraw tickets omit `requested_lamports` (the on-chain u64::MAX
    sentinel carries no meaningful amount).

**Filters**
22. Exact vs ILIKE per §4.6; `country` = full English name, `country_code` = ISO (map only);
    `pool_name` = lowercase internal name (§2.7).
23. Unknown parameters 400 — with one exception: `/api/pools` accepts
    `validators_count_eq/gte/lte` and silently ignores them (§5.8). Everywhere else a 200 means
    every filter you passed was applied. Endpoints without query parameters (`/api/health`,
    `/api/epoch`, `/api/validators/:pubkey`) ignore query strings entirely.
24. `/api/history?validator=` matches the **current** identity pubkey only — historical
    identities and vote pubkeys return an empty 200, not an error. Resolve the current identity
    via `/api/validators/:pubkey` first (it accepts all three).
25. Never report a raw delinquent count or a "longest delinquent" without segmenting per §2.4 —
    the delinquent set mixes live incidents (all the stake) with dead corpses (all the duration),
    and corpse dust also pollutes `stake_change` feeds. State which population the answer covers.
26. Event volume is pathologically skewed: a single validator can hold >10% of the entire event
    log (a dual-instance gossip duel emits tens of thousands of ip/port flips per epoch). Count
    with `page_size=1` before pulling any event list, and collapse flip storms per §7.15 instead
    of paging them.
