Vortex Data API — Reference ============================ Base URL: https://api.vx.tools Methods: every endpoint accepts BOTH GET and POST. GET reads its parameters from the query string; POST reads them from a JSON body (`content-type: application/json`). Field names are identical in both modes (camelCase on the wire). Response: application/json; charset=utf-8 Auth: none (public read-only API) This document is the canonical machine-readable reference. It's served verbatim at /llms.txt. Field names below are wire-format (camelCase). Quick example: curl https://api.vx.tools/epochs/current # => {"epoch": 1047} # GET form — share-friendly URL curl 'https://api.vx.tools/epochs/leaderboard/voting?epoch=969' \ | jq '.records[0:2]' # POST form — equivalent curl -sX POST https://api.vx.tools/epochs/leaderboard/voting \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' GET-specific gotchas: - List parameters (today only `slots` on /blocks/insights) must be passed as a comma-separated string: `?slots=1,2,3` — `serde_urlencoded` doesn't decode repeated keys. POST bodies still use a JSON array. - Field names are the camelCase wire names: `?voteKey=...&limit=5`, not `?vote_key=...`. - Optional fields can simply be omitted from the query string; the server treats them as null (same as POST `{}`). ================================================================ SHARED TYPES ================================================================ These types appear in multiple endpoint responses. Wire format uses camelCase regardless of the Rust source names. BlockIncome ----------- { "baseFees": u64, // lamports from base transaction fees "priorityFees": u64, // lamports from priority (compute-unit) fees "mevTips": u64 // lamports captured as MEV via tip programs } Note: "totalFees" = baseFees + priorityFees; "totalIncome" adds mevTips. LatencyDistributionEntry ------------------------ { "latency": u8, // 0..=31; see "Latency buckets" gotcha "stakePct": f64, // 0..1; share of voting stake in this bucket "validatorCount": u64 // distinct validators in this bucket } ================================================================ ENDPOINTS ================================================================ ---------------------------------------------------------------- GET / ---------------------------------------------------------------- Health-check / hello. Returns a static "Hello, World!" string. $ curl https://api.vx.tools/ Hello, World! ---------------------------------------------------------------- GET /usage ---------------------------------------------------------------- Operational, not part of the data API: request counts per endpoint, method, caller origin and status class since the counter file was created (persisted across restarts). Used to decide what can be deprecated. Cache-Control: no-store. Requests to /usage itself are not counted. $ curl -s https://api.vx.tools/usage | jq '.totals' { "since": 1790900000, "updated": 1790903600, "totals": [ { "method": "GET", "route": "/validators", "count": 1234 }, ... ], "rows": [ { "method": "GET", "route": "/validators", "origin": "app.vx.tools", // Origin header host; "(none)" for curl/scripts "agent": "browser", // User-Agent class: browser|curl|python|node|go|rust|other|(none) "status": "2xx", "count": 1200 }, ... ] } ---------------------------------------------------------------- GET|POST /epochs/current ---------------------------------------------------------------- Current mainnet epoch, plus where the cluster is in the TowerBFT -> Alpenglow (SIMD-0326) transition. $ curl https://api.vx.tools/epochs/current {"epoch": 970, "alpenglow": null} Response: { "cluster": "mainnet-beta", // or "testnet" / "devnet": what this instance serves "epoch": u64, "alpenglow": null | { "phase": "scheduled" | "migrating" | "active", "activationSlot": u64, // first slot of the activation epoch; while // "scheduled", the expected slot (next epoch start) "genesisSlot": u64 | null, // G, the last TowerBFT block; null until "active" "cutoverEpoch": u64 | null // epoch containing G; null until "active" }, "progress": null | { // where the cluster is in `epoch`; null until the // first refresh after a restart "slotIndex": u64, // slot within the epoch "slotsInEpoch": u64, "absoluteSlot": u64, "slotSeconds": f64 | null, // observed seconds per slot, last ~30 minutes "estimatedEndTime": i64 | null,// unix seconds the epoch's last slot is expected "readAt": i64 // unix seconds the slot figures were read }, "totals": null | { // cluster totals for `epoch` so far; null until the // first refresh after a restart "epoch": u64, "processedSlots": u64 | null, // slots the block data covers (produced + skipped); // trails progress.slotIndex by the ingestion lag "blocksProduced": u64 | null, "skippedSlots": u64 | null, "blockRevenue": { "baseFees": u64, "priorityFees": u64, "mevTips": u64 } | null, // gross lamports (all null where no block ingestion // runs, i.e. testnet) "activeValidators": u64 | null,// staked vote accounts not delinquent "stakedValidators": u64 | null,// vote accounts with activated stake "readAt": i64 }, "ingestion": null | { // data freshness, refreshed every 3 s (rankMaps, // footerEpochs every minute) "finalizedSlot": u64, "blocks": null | { "lastSlot": u64, "lagSlots": u64, "updatedAt": unix_s }, "footers": null | { "lastSlot": u64, "lagSlots": u64, "updatedAt": unix_s }, "rankMaps": { "currentEpoch": bool, "nextEpoch": bool }, "footerEpochs": [u64], // epochs with any footer ingested, ascending "checkedAt": unix_s } } `progress` is refreshed every 3 s from getEpochInfo (`readAt` says when), so it can trail the chain by up to 3 s plus one RPC round trip; poll this endpoint every few seconds for live counters. `estimatedEndTime` is computed at each refresh as now + remaining slots x `slotSeconds`, where `slotSeconds` is the observed slot time from getRecentPerformanceSamples over the last half hour -- not the nominal 400 ms, which mainnet no longer runs at (0.27 s observed on 2026-10-05). Both are null if the samples could not be read. `totals` is refreshed every 3 s. The block figures are the ingestor's cluster row for the epoch (the all-zero address in /epochs/leaderboard/income), which covers every slot, including leaders without a validator row. Do not add that row to the validator rows: the leaderboard's records include it, so summing every record counts each slot and lamport twice. `ingestion` says how far each data source has got, so a page can label recent data instead of showing it as missing: - blocks: the Tower block-ingestor — fees, MEV, block times, skip flags, Tower vote data. On mainnet it reads the public ledger warehouse, which can fall behind; `lagSlots` is how far. null where it does not run (testnet). - footers: the Alpenglow ingestor — votes, finalization, participation (our own nodes' upload, near real time). null until it runs (before the cutover). footers.lastSlot is a promise about /slots: every slot <= lastSlot has its row (outcome) readable there, and every slot <= lastSlot - 8 has its votes, unless the block at slot + 8 was skipped or is a warehouse gap (then votes stay null for good). Finalization fills in a few slots later (up to ~256). The ingestor writes the rows before it publishes lastSlot, /slots reads storage directly (no cache), and this block is refreshed every 3 s, so lastSlot can only be behind /slots, and it never moves backwards. /consensus/live is a different, earlier source: in-flight slots straight from the vote pools, usually ahead of lastSlot by design. - rankMaps: whether the current and next epoch's rank maps are stored (next is needed before that epoch starts). - footerEpochs: the Alpenglow epochs with any footer ingested, ascending (checked every minute). Epoch pickers for consensus data (leaderboard, cluster history, participation) should offer only these: epochs after the cutover but before ingestion began return rows with lastIngestedSlot null and every counter at 0. Usually contiguous up to the current epoch, but a re-ingest can leave gaps. Empty before the cutover or while nothing is ingested. E.g. "fees up to slot {blocks.lastSlot}, {blocks.lagSlots × progress.slotSeconds} s behind". `cluster` names the cluster this API instance serves. Deployments: api.vx.tools (mainnet, production), api-staging.vx.tools (mainnet data, staging code) and api-testnet.vx.tools (testnet data, staging code; Alpenglow active there since epoch 1042). A dashboard with a cluster selector switches the base URL and reads `cluster` to label the data; every endpoint below is served by every instance, against that instance's cluster. `alpenglow` is null while nothing is scheduled. "scheduled" appears as soon as Anza creates the feature account, one epoch ahead of activation (a pending activation can still be revoked). Both slots come from chain accounts (the feature account and the genesis-certificate PDA), refreshed every minute. Consensus of any epoch: `epoch < cutoverEpoch ? "tower" : "alpenglow"` -- the epoch containing G counts as Alpenglow. The vote-rank leaderboard and latency endpoints (/epochs/leaderboard/voting, /epochs/vote-stats, /epochs/votes, the latency half of /blocks/insights) are historical products that end at cutoverEpoch - 1; cap epoch pickers for them there. Alpenglow-era participation is a separate product on /epochs/participation, /epochs/leaderboard/participation, /epochs/participation/history, /epochs/cluster/history, /epochs/leader-schedule, /alpenglow/rank-map, /slots/footer, /slots and /slot, which start at cutoverEpoch. The cutover epoch is mixed: its slots up to and including G (normally the first ~5000) are TowerBFT, everything after is Alpenglow. Per-slot answers follow the slot, not the epoch: /slot and /blocks/insights carry a `consensus` per slot. For the Tower part of the cutover epoch the chain context is kept but nothing counts: /slots and the cluster timeline give each slot's outcome (block / skipped, from the Tower block-ingestor), with no votes, footer or finalization; validator timelines read 0 (not eligible) and no participation count, rank or gap includes them; /slots/footer 404s (Tower blocks have no footer). The epoch-level Tower vote endpoints answer for epochs up to cutoverEpoch - 1 only; /slot and /blocks/insights still serve those slots' Tower latency distribution. ---------------------------------------------------------------- GET|POST /validators ---------------------------------------------------------------- Directory of every validator we know about. Sorted by nodeAddress for byte-identical responses across refreshes. Cached with Cache-Control: public, max-age=60, stale-while-revalidate=300. $ curl -s https://api.vx.tools/validators | jq '.validators[0]' { "nodeAddress": "Foo...", "voteAddress": "Bar...", // null if not registered "name": "Acme" // null if no display name } Response: { "validators": [ { "nodeAddress": string, "voteAddress": string | null, "name": string | null }, ... ] } Gotcha: the synthetic ideal-voter baseline (the all-zeroes address) is already filtered out of this list — it is NOT a real validator. Source: every staked vote account from `getVoteAccounts` (current and delinquent), named from the on-chain validator-info Config accounts. Refreshed every 60 s (names hourly). Geography (the `datacenter`, `continent`, `country`, `provider` columns on the leaderboards and `dataCenterKey`/`geo` on /validators/status) comes from the node's gossip address looked up in DB-IP's free Lite databases (https://db-ip.com, CC BY 4.0 — consumers displaying it must credit "IP Geolocation by DB-IP"). The key is `ASN-CC-City`; `0--Unknown` when nothing resolved. `continent` and `country` are DB-IP's English names (`Europe`, `United States`, `United Kingdom`), no local renaming. City names drop DB-IP's district suffix and Tokyo's wards collapse to `Tokyo`. Accuracy check (2026-10-02): round-trip times from Tokyo to 625 validator IPs found no country placement that violates the speed-of-light bound and one city placement that does. City vocabulary differs from MaxMind-based sources (`Frankfurt am Main` where MaxMind says `Rödelheim`), so keys are not comparable across sources. ---------------------------------------------------------------- GET|POST /validators/stake ---------------------------------------------------------------- Live stake and advertised commission for one validator, read from the chain at request time (stake accounts + StakeHistory sysvar + the vote account). Accepts EITHER `identity` OR `voteKey`; `voteKey` wins if both are given. $ curl -s 'https://api.vx.tools/validators/stake?identity=Foo...' { "identity": "Foo...", "voteKey": "Vote...", "epoch": 1047, "activeStake": 10023433163628, "activatingStake": 0, "deactivatingStake": 0, "inflationRewardsCommissionBps": 500, // u16 | null "blockRevenueCommissionBps": 10000 // u16 | null } Request: { "identity": string | null, "voteKey": string | null } Notes: - The stake figures are for the in-progress epoch and move within it as delegations change; the frozen per-epoch series is on /epochs/income. - The commission rates are what the vote account advertises NOW. Under SIMD-0249 they are not what the current epoch pays out at; /epochs/income carries the effective per-epoch rates. `null` if the vote account could not be read. Answers are cached per vote account for 5 minutes; a cached answer is also served when a refetch fails or exceeds 8 s, so a slow RPC degrades to slightly stale rather than to an error. Errors: 400 (body `null`) if neither key resolves to a known validator; 500 if the stake history or stake accounts could not be fetched and nothing is cached; 504 if the stake-account scan exceeded 8 s and nothing is cached. ---------------------------------------------------------------- GET|POST /validators/stake-changes ---------------------------------------------------------------- Who started or stopped staking to one validator, epoch by epoch, oldest first. Epoch E compares the vote account's effective stake at the end of E-1 with the end of E, by delegator (a stake account's withdraw authority), with reward compounding removed. $ curl -s 'https://api.vx.tools/validators/stake-changes?identity=Foo...&limit=5' | jq '.[-1]' { "epoch": 1048, "stakeBefore": 120000000000000, "stakeAfter": 145200000000000, // lamports, effective "rewardGrowth": 80000000000, // part of the change that is compounding "inflow": 26000000000000, // net gains beyond rewards "outflow": 880000000000, // net losses beyond rewards "net": 25120000000000, // inflow - outflow "delegatorsBefore": 410, "delegatorsAfter": 415, "newDelegators": 7, "exitedDelegators": 2, "top5Share": 0.41, // f64 | null: five largest delegators / stakeAfter "byLabel": [ { "label": "JitoSOL", "inflow": 20000000000000, "outflow": 0 }, { "label": "other", "inflow": 6000000000000, "outflow": 880000000000 } ], "changes": [ // largest |net| first, |net| >= 1 SOL only { "withdrawer": "6iQK...", "staker": "6iQK...", "label": { "name": "JitoSOL", "kind": "stake-pool" }, // null if unknown "kind": "new", // "new" | "increased" | "decreased" | "exited" "before": 0, "after": 20000000000000, "net": 20000000000000, // i64 lamports, beyond rewards "counterparts": [ // where it came from (gain) / went (loss) this epoch { "voteKey": "Abc...", "name": "Some Validator", "net": -20000000000000 } ] } ], "moreChanges": 0, // changes beyond maxChanges left out "pools": [ // stake each known pool / SFDP held here at the // end of the epoch, changed or not; largest first { "label": "SFDP", "kind": "sfdp", "lamports": 40000000000000, "share": 0.28 }, { "label": "JitoSOL", "kind": "stake-pool", "lamports": 20000000000000, "share": 0.14 } ], "stakeSet": "stayed" // "stayed" | "entered" | "left" | null (see notes) } Request: { "identity": string | null, "voteKey": string | null, "limit": u64, // epochs back from current, 1..=50 "maxChanges": u64 | null } // per epoch, default 50 Notes: - Source: Jito's public per-epoch stake-meta file (every stake account of every staked validator, Jito-running or not), published within hours of an epoch ending. The current epoch never has a row; the last completed one appears once the file is out. - Stake still activating is not counted until the epoch it becomes effective, so this is stake that started earning, not stake that was delegated. - Delegator = withdraw authority, so a stake pool is one delegator however many stake accounts it spreads over the validator, and its internal merges/splits net out. label comes from the withdrawer, else the staker: stake pools (from Sanctum's LST list, ~240), Marinade and SFDP. Everything else (individuals, funds, custodians) is unlabelled and grouped as "other" in byLabel. - Only stake the runtime counts is included: a vote account outside the epoch's stake set (e.g. dropped by the VAT filter for having no BLS key) has no row, even though getVoteAccounts lists its delegations. When it enters or leaves the set, stakeSet says "entered"/"left" and every delegation shows as new/exited at once; that is not delegators moving. - pools is a time series across the returned epochs: the same label in consecutive items is that pool's stake on this validator over time. Marinade's two authorities are merged into one entry. share is lamports / stakeAfter. A pool with no stake here that epoch is absent. Known pools cover ~17% of all stake. - Changes under 1 SOL are not listed but are in the totals. - Errors: 400 (body []) if limit is out of range or neither key resolves; 500 if the read failed. ---------------------------------------------------------------- GET|POST /validators/commission ---------------------------------------------------------------- Commission-change events for one validator's vote account over the last N epochs, oldest first, from the ingested transaction stream. $ curl -s 'https://api.vx.tools/validators/commission?identity=Foo...&limit=20' [ { "slot": 452304123, "target": "inflationRewards", // "inflationRewards" | "blockRevenue" | "legacy" "bps": 500, "signature": "5Kq..." } ] Request: { "identity": string | null, "voteKey": string | null, "limit": u64 } // epochs to look back, 1..=100, default 20 Notes: - `target` says which rate moved. `legacy` is the pre-SIMD-0291 `UpdateCommission(u8)` instruction, which sets the inflation-rewards rate (recorded here as bps = percent x 100) and leaves block revenue untouched. - Covers the inclusive epoch range [current_epoch - limit, current_epoch]. Most epochs have no events; an empty array is the normal answer for a validator that has not changed its rates. Errors: 400 (empty array) if `limit` is out of range or no key resolves; 500 on a read failure. ---------------------------------------------------------------- GET|POST /validators/status ---------------------------------------------------------------- Operational status of one validator, with a verdict per check. Built from in-memory snapshots refreshed in the background (gossip and vote accounts every 60 s, vote-account state every 5 min, on-chain validator info and SFDP data hourly); no I/O per request. Cache-Control: public, max-age=30, stale-while-revalidate=60. $ curl 'https://api.vx.tools/validators/status?identity=' $ curl 'https://api.vx.tools/validators/status?voteKey=' Request: one of { "identity": string } // node identity { "voteKey": string } // vote account Response: { "identity": string, "voteAccount": string, "epoch": u64, "slot": u64, // snapshot epoch and tip "asOf": u64, // unix seconds of the snapshot "info": { // on-chain validator-info, or null "name", "website", "details", "keybaseUsername", "iconUrl" }, "dataCenterKey": string | null, // "ASN-CC-City" of the gossip ip (DB-IP lite) "geo": { "asn": u32 | null, "asnOrganization": string | null, "countryCode": string | null, "city": string | null, "latitude": f64 | null, "longitude": f64 | null } | null, "concentration": { // null if not geolocated "dataCenterKey": string, "dataCenterStakeShare": f64, "dataCenterValidators": usize, "asn": u32, "asnStakeShare": f64, "asnValidators": usize } | null, "software": { // from gossip, or null if absent "clientId": string | null, // "Agave", "JitoLabs", "Firedancer", ... "family": "agave" | "frankendancer" | "firedancer" | null, "flavor": string | null, // "BAM", "Jito", "Harmonic", ... "version": string | null, "featureSet": u32 | null, "shredVersion": u16 | null, "gossipIp": string | null }, "stake": { "activatedStake": u64, "shareOfTotal": f64 | null, // of cluster.totalActivatedStake "epochVoteAccount": bool, "delinquent": bool, "lastVote": u64, "rootSlot": u64, "slotsBehind": u64 }, "identityBalance": { // the account that pays vote fees "lamports": u64, "estimatedVoteFeesPerEpoch": u64, // 432000 slots x 5000; 0 under Alpenglow "epochsOfRunway": f64 | null } | null, "voteState": { // read from the vote account, or null "lamports": u64, "nodePubkey": string, "authorizedWithdrawer": string, "authorizedVoter": string | null, "nextAuthorizedVoter": string | null, "inflationRewardsCommissionBps": u16, "blockRevenueCommissionBps": u16, "blsPubkeySet": bool, // The account's CURRENT settings. Commission rates and the block-revenue collector // are not necessarily what the runtime applies this epoch: for that read // pending..effective when present (see `pending` below). "inflationRewardsCollector": string | null, // SIMD-0232 payee of the inflation commission; // the vote account unless redirected "blockRevenueCollector": string | null, // payee of the block-revenue commission; // the node identity unless redirected // (both null if the account is not VoteStateV4) "lastVotedSlot": u64 | null, "rootSlot": u64 | null, "pending": { // settings changed on the account but not in effect yet; // {} = none; null = unknown (no stored terms describe // this vote account for the current epoch). Only // changed fields appear. "": { "effective": u16 | string, // in effect for the current epoch's payout "next": u16 | string, // the next value to take effect "fromEpoch": u64 | null, // first epoch whose payout uses `next`; null if it // cannot be told apart between epoch+1 and epoch+2 "later": { "value": u16 | string, "fromEpoch": u64 } | null // changed again after `next` was locked in } } | null } | null, // `pending` fields: // inflationRewardsCommissionBps, blockRevenueCommissionBps (numbers), // blockRevenueCollector (base58) - epoch E pays out on the terms the vote // account held at the START of E-1 (SIMD-0249; fee distribution reads the // block-revenue collector from the same snapshot, SIMD-0232). So a change made // during epoch C takes effect at C+2, one made during C-1 at C+1. Compared only // on an identity's highest-staked vote account. // authorizedVoter (base58) - from the vote account's own schedule; the next // epoch's voter, fromEpoch = epoch+1 (same as nextAuthorizedVoter). // Locked in: a value with fromEpoch = epoch+1 is fixed (the runtime snapshotted it at // the start of this epoch; a voter for epoch+1 cannot be re-authorized). A value at // epoch+2, and any `later`, is the account's live setting and can still change until // the current epoch ends. Same rule for rates, the block-revenue collector and voter. // Never pending: inflationRewardsCollector (read when rewards are paid, so a change // applies to the next payout). blockRevenueCommissionBps cannot differ from 10000 // until block-revenue sharing (SIMD-0123) activates. "vat": { // Validator Admission Ticket (SIMD-0357) "alpenglowActive": bool, "voteAccountLamports": u64, "minimumBalance": u64, // rent (+ one ticket once Alpenglow is live) "vatLamportsPerEpoch": u64, // 1.0 SOL at 250 ms slots "blsPubkeySet": bool, "epochsFunded": u64, "admitted": bool, // prediction: would pass the filter at the next // boundary (admission to nextEpoch + 1) "failureReason": string | null, // agave's reason when `admitted` is false "admittedThisEpoch": bool | null,// decided: in this epoch's stake set (ep.ad) "nextEpoch": u64, "admittedNextEpoch": bool | null // decided: in nextEpoch's set, frozen at this // epoch's first slot; null = no admission rows } | null, "sfdp": { // null unless the server serves mainnet-beta "program": SfdpProgramModel | null, // same object as the summary's sfdpProgram "participant": { // null if never applied "mainnetIdentity", "testnetIdentity" | null, "state": "Approved" | "Pending" | "TestnetOnboarded" | "Rejected" | "Retired", "onboardingEpoch": u64 | null } | null, "stake": { // the program's delegation to this validator "effective": u64, "activating": u64, "deactivating": u64, // lamports "shareOfValidatorStake": f64 | null, // effective / activatedStake "shareOfProgram": f64 | null, // effective / cluster SFDP total "externalStake": u64, // activatedStake - effective: what gets matched "matching": u64 | null, // effective - baseline (the matched part) "expectedDelegation": u64 | null, // min(cap, baseline + matchRate*externalStake); 0 if not eligible "shortfall": u64 | null, // expectedDelegation - effective, >= 0; 0 while inTransition "mainActivationEpoch": u64 | null, // activation epoch of the largest live SFDP account "inTransition": bool, // that account activated this epoch or last "criteria": { "approved": bool, "stakeWithinLimit": bool, // total stake <= 1M SOL "commissionWithinLimit": bool | null, // inflation commission <= 5% "versionMeetsMinimum": bool | null, // current-epoch SFDP minimum "dataCenterWithinLimit": bool | null, // data centre <= 15% of stake "asnWithinLimit": bool | null, // ASN <= 25% of stake "eligible": bool } // all measurable criteria pass } | null, // null only until the first scan "mainnet": VersionCompliance | null, "testnet": { // participants with a testnet identity only "identity": string, "epoch": u64, "software": | null, "compliance": VersionCompliance | null } | null }, "cluster": { // what the checks and shares were computed against "alpenglowActive": bool, "slotMs": u64, "vatLamportsPerEpoch": u64, "voteAccountRentExemptMinimum": u64, "totalActivatedStake": u64, // sum over every staked validator "sfdpTotalStake": u64 | null // cluster-wide effective SFDP lamports }, "checks": [ { "id": string, "status": "ok"|"warn"|"fail"|"unknown", "summary": string }, ... ] } VersionCompliance: { "family": "agave" | "frankendancer" | "firedancer", "runningVersion": string, "current": EpochRequirement | null, // the current epoch's row "upcoming": [ EpochRequirement, ... ], // every published later epoch "firstFailingEpoch": u64 | null // first epoch the build misses } EpochRequirement: { "epoch": u64, "minVersion": string | null, "maxVersion": string | null, "inheritedFromPrevEpoch": bool, "satisfied": bool | null } Checks (`id`): gossip identity visible in gossip (fail if not) voting fail = delinquent; warn = lastVote > 32 slots behind (sfdp_* checks exist only on mainnet-beta deployments; elsewhere the SFDP section is null and the summary's sfdp* fields are null.) sfdp_version_mainnet fail = below the current epoch's minimum; warn = meets it but a published later epoch raises it sfdp_version_testnet same against testnet rules, for SFDP participants; fail if the testnet identity is not in testnet gossip identity_balance runway in epochs of vote fees: fail < 1, warn < 3; ok with no runway once Alpenglow makes votes free vote_authority fail = vote account's node pubkey is not the identity, or no authorized voter; warn = authorized voter is a separate key, or rotates away next epoch block_revenue_collector warn = this epoch's block fees are paid to an account other than the identity: the collector in effect (pending.blockRevenueCollector.effective when a change is pending, else voteState.blockRevenueCollector). Since SIMD-0232 an identity change does not move the collector, so after a rotation fees keep going to the old identity until it is updated; a deliberate redirect also warns concentration warn = this validator's data centre holds > 15% or its ASN > 25% of cluster stake (the SFDP limits); unknown = not geolocated vat first, decided: excluded from this epoch's or the next epoch's (already frozen) stake set -> fail, with or without Alpenglow: the VAT filter runs on 4.3 regardless. Epoch N's set is filtered at the start of N-1, and the same filtered set receives epoch N-2's inflation rewards, so an excluded vote account gets no leader slots in N and nothing for N-2, even while it votes. Then predicted for the boundary after: fail = would fail admission (no BLS key, balance below minimum, no stake); warn = fewer than 3 tickets funded (post-Alpenglow) or less than rent plus one ticket (pre-Alpenglow) "unknown" means an input was missing (not in gossip, API down, version string unparseable), not that the check passed. Gotchas: - Pre-release builds compare below their release under semver, so `4.3.0-rc.1` does NOT satisfy a `4.3.0` minimum. - `estimatedVoteFeesPerEpoch` is an upper bound (one vote transaction every slot). Real spend is lower when votes are batched. - The SFDP API publishes at least four epochs ahead. `firstFailingEpoch` is the earliest published epoch the running build misses; a null does not promise anything about epochs not yet published. ---------------------------------------------------------------- GET|POST /validators/status/summary ---------------------------------------------------------------- Every staked validator's status in one response, for tables. Each row is /validators/status for that validator projected to sortable facts plus the check verdicts WITHOUT their summaries. Built from the same snapshot with the same rules, so a verdict here never disagrees with the per-validator page. Sorted by identity. Cache-Control: public, max-age=30, stale-while-revalidate=60. $ curl -s https://api.vx.tools/validators/status/summary | jq '.validators[0]' { "identity": "...", "voteAccount": "...", "name": "Acme", "activatedStake": 10023433163628, "delinquent": false, "software": { "clientId": "AgaveBam", "family": "agave", "version": "4.3.0" }, "countryCode": "DE", "country": "Germany", "continent": "Europe", "sfdpState": "Approved", // null if never applied "sfdpStake": 72500000000000, // effective SFDP lamports; null until first scan "externalStake": 45000000000000, // stake not from the program "sfdpShortfall": 0, // lamports below the program's rule "sfdpInTransition": false, // bulk of the delegation (re)created this epoch or last "sfdpEligible": true, // measurable residual criteria all pass "epochsOfRunway": 9.2, // null if unknown or votes are free "vatEpochsFunded": 21, // null if vote account not read "pendingCommissionIncrease": false, // voteState.pending raises the inflation commission; // null when pending is unknown "dataCenterStakeShare": 0.048, // null if not geolocated "asnStakeShare": 0.070, "checks": [ { "id": "gossip", "status": "ok" }, ... ] } Response: { "epoch": u64, "asOf": u64, "sfdpTotalStake": u64 | null, // cluster-wide effective SFDP lamports "sfdpProgram": SfdpProgramModel | null, "validators": [ row, ... ] } Rows also carry "externalStake" (activatedStake - sfdpStake) and "sfdpShortfall" (see SFDP matching below). Check ids, statuses and nullability are exactly those of /validators/status; fetch that endpoint for the `summary` text and the underlying numbers. ---------------------------------------------------------------- GET|POST /validators/geo ---------------------------------------------------------------- Every staked validator with its geolocation (gossip address through DB-IP lite; see /validators). Sorted by nodeAddress. Cache-Control: public, max-age=60, stale-while-revalidate=300. $ curl -s https://api.vx.tools/validators/geo | jq '.validators[0]' { "nodeAddress": "...", "voteAddress": "...", "name": "Acme", "activatedStake": 123456789, "dataCenterKey": "24940-DE-Falkenstein", // null if unresolved "geo": { "asn": 24940, "asnOrganization": "Hetzner Online GmbH", "countryCode": "DE", "city": "Falkenstein", "latitude": 50.47, "longitude": 12.37 } // null if unresolved } Response: { "epoch": u64, "asOf": u64, "validators": [ entry, ... ] } ---------------------------------------------------------------- GET|POST /cluster/distribution ---------------------------------------------------------------- Cluster-wide activated-stake distribution by client and by location, from the same 60 s snapshot as /validators/status. Every list is sorted by stake, largest first. Cache-Control: public, max-age=60. $ curl -s https://api.vx.tools/cluster/distribution | jq '.clients' [ { "family": "agave", "validators": 619, "stake": …, "stakeShare": 0.844 }, { "family": "firedancer", "validators": 49, "stake": …, "stakeShare": 0.119 }, { "family": "frankendancer", "validators": 8, "stake": …, "stakeShare": 0.036 }, { "family": null, "validators": 9, "stake": …, "stakeShare": 0.001 } ] Response: { "epoch": u64, "asOf": u64, "totalActivatedStake": u64, "clients": [ { "family": "agave"|"frankendancer"|"firedancer"|null, validators, stake, stakeShare } ], "clientIds": [ { "clientId": string|null, "family": …, validators, stake, stakeShare } ], "dataCenters": [ { "dataCenterKey": "ASN-CC-City", validators, stake, stakeShare } ], "asns": [ { "asn": u32, "organization": string|null, validators, stake, stakeShare } ], "unlocated": { validators, stake, stakeShare } } // not in gossip Gotchas: - family null = not in gossip, or an unrecognised client id with an unparseable version. - dataCenterKey uses DB-IP's city (see /validators), so a site can be grouped differently from MaxMind-based sources. ASN grouping is robust; multiple ASNs owned by one company are NOT merged. ---------------------------------------------------------------- GET|POST /cluster/required-versions ---------------------------------------------------------------- The Solana Foundation Delegation Program's required client versions per epoch, for both clusters, as published at api.solana.org (refreshed hourly). Cache-Control: public, max-age=300, stale-while-revalidate=3600. $ curl https://api.vx.tools/cluster/required-versions | jq '.mainnetBeta[0]' { "epoch": 1046, "agaveMinVersion": "4.3.0", "agaveMaxVersion": null, "frankendancerMinVersion": "0.1204.40300", "frankendancerMaxVersion": null, "firedancerMinVersion": "26.8.5", "firedancerMaxVersion": null, "inheritedFromPrevEpoch": true } Response: { "epoch": u64, // current mainnet epoch "mainnetBeta": [ row, ... ], // sorted by epoch "testnet": [ row, ... ] } Gotcha: the Foundation's API calls the Frankendancer columns `firedancer_*` and the full Firedancer client `firedancer_full_*`. They are renamed here to `frankendancer*` and `firedancer*`. ---------------------------------------------------------------- GET|POST /blocks/income ---------------------------------------------------------------- Per-slot income for a single validator within one epoch. $ curl -s 'https://api.vx.tools/blocks/income?identity=Foo...&epoch=969' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/blocks/income \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "epoch": 969}' | jq '.[0]' { "slot": 435456000, "leader": "Foo...", "income": { "baseFees": 12345, "priorityFees": 67890, "mevTips": 0 } } Request: { "identity": string, // base58 node address "epoch": u64 } Response: array of { "slot": u64, "leader": string, "income": BlockIncome | null } // null = skipped or not yet ingested Errors: 400 if identity isn't a valid pubkey; 500 on bigtable failure. ---------------------------------------------------------------- GET|POST /blocks/insights ---------------------------------------------------------------- Per-slot leader + skip + vote-latency distribution for arbitrary slots. $ curl -s 'https://api.vx.tools/blocks/insights?slots=435456000,435456001' \ | jq '.results[0]' $ curl -sX POST https://api.vx.tools/blocks/insights \ -H 'content-type: application/json' \ -d '{"slots": [435456000, 435456001]}' | jq '.results[0]' { "slot": 435456000, "isSkipped": false, "noVotes": false, "leaderNodeAddress":"Foo...", "distribution": [ { "latency": 0, "stakePct": 0.12, "validatorCount": 89 }, { "latency": 1, "stakePct": 0.58, "validatorCount": 1240 }, ... ] } Request: { "slots": [u64, ...] } // max 1000 per request; GET form uses // a comma-separated string Response: { "results": [ { "slot": u64, "isSkipped": bool | null, // null = unknown / not ingested "noVotes": bool | null, // true = block had no vote txs "leaderNodeAddress": string | null, "distribution": [LatencyDistributionEntry, ...] | null, "consensus": "tower" | "alpenglow" // per slot: tower through G }, ... ] } Gotchas: - `distribution` is non-null only for confirmed slots whose vote histogram has been computed. Skipped, pending, and unknown collapse to null. `consensus: "alpenglow"` slots never get one (no vote transactions to time); use /slot or /slots for them. - Requests exceeding 1000 slots are rejected. ---------------------------------------------------------------- GET|POST /epochs/income ---------------------------------------------------------------- Per-epoch income summary for one validator over the last N epochs. $ curl -s 'https://api.vx.tools/epochs/income?identity=Foo...&limit=5' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/epochs/income \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "limit": 5}' | jq '.[0]' { "epoch": 970, "startTime": 1769900000, // i64 | null: unix seconds of the epoch's first block "endTime": 1770008000, // i64 | null: of its last block; null while in progress "stake": 123456789012, // effective during the epoch (rewards basis) "scheduledStake": 123400000000, // u64 | null: leader-schedule stake "activatingStake": 5000000000, // u64 | null "deactivatingStake": 0, // u64 | null "inflationRewardsCommissionBps": 500, // u16 | null "blockRevenueCommissionBps": 10000, // u16 | null "totalSlots": 1728, "confirmedSlots": 1701, "skippedSlots": 27, "totalIncome": BlockIncome, "medianIncome": BlockIncome, "minIncome": BlockIncome, "maxIncome": BlockIncome } Request: { "identity": string, "limit": u64 } // 1..=100, else 400 Response: array ordered oldest-first, covering the inclusive range [current_epoch - limit, current_epoch] — that is `limit + 1` epochs when every row exists. Epochs without a row are simply absent. Notes: - `stake`: lamports effective during the epoch, the stake its inflation rewards were computed on. Provisional while the epoch runs (read at its start), final once it ends. A vote account the VAT filter dropped at the epoch's end earns nothing for it and counts 0. - `scheduledStake`: the frozen stake the epoch's leader schedule (and under Alpenglow its vote weights) was built from. The runtime snapshots it at the start of the epoch before, before that boundary's rewards are credited, so it trails `stake` by about one epoch of stake movement. `null` when not recorded (recorded live from epoch 1056; earlier epochs only where backfilled). - `activatingStake` / `deactivatingStake`: lamports warming up or cooling down during the epoch. `null` means unknown, not zero — only a writer holding the whole stake-delegation set (the snapshot tool) can compute them, so snapshot-covered epochs have them and others do not. Render the two cases differently. - `inflationRewardsCommissionBps` / `blockRevenueCommissionBps`: the rates that governed THIS epoch's payout. Under SIMD-0249 a rate change takes effect on payouts at least a full epoch later, so this is deliberately not the rate advertised during the epoch. For the live advertised rate see /validators/stake. `null` when the writer did not know the rate. ---------------------------------------------------------------- GET|POST /epochs/earnings ---------------------------------------------------------------- Per-epoch on-chain earnings for one validator: the validator's take of each revenue stream, what voting cost, and the net. Flows only — no balances (an account balance mixes operating results with the operator's own deposits and withdrawals; "do I need to top up" is on /validators/status). Accepts EITHER `identity` OR `voteKey`. $ curl -s 'https://api.vx.tools/epochs/earnings?identity=Foo...&limit=10' | jq '.[-2]' { "epoch": 1046, "startTime": 1790470000, // i64 | null: unix seconds of the epoch's first block "endTime": 1790578000, // i64 | null: of its last block; null while in progress "consensus": "tower", // "tower" | "alpenglow" "blockRevenue": { "baseFees": …, "priorityFees": …, "mevTips": … }, // gross, as produced "rates": { "inflationRewardsCommissionBps": 500, "blockRevenueCommissionBps": 10000, "mevCommissionBps": 800 }, // u16 | null: Jito tip-distribution rate (em.cmm) "income": { "voteAccountReward": 89634470, // u64 | null: everything the boundary block paid the vote account "inflationCommission": 89634470, // u64 | null: = voteAccountReward under tower; null under alpenglow "blockRevenueCommission": …, // (baseFees+priorityFees) × cmb/10000 "mevCommission": …, // mevTips × mevCommissionBps/10000; = mevTips if null "votingReward": null, // u64 | null: Alpenglow, GROSS earned by its votes "producerBonus": null }, // u64 | null: Alpenglow, GROSS producer bonus "sharedWithStakers": 0, // block fees passed to stakers (SIMD-0123) "costs": { "voteFees": 2157545000, // Tower: estimate, see below; 0 under Alpenglow "voteFeesExact": false, "vat": 0, // Alpenglow: ticket burned; 0 under Tower "vatExact": true }, // false = slot-tier constant, row has no debit yet "net": 17000000, // i64 | null: income − costs; null while // inflationCommission is null "accounts": { "inflationCommission": "voteAccount", "blockRevenueCommission": "identity", // "voteAccount" once SIMD-0123 is live "mevCommission": "identity", "voteFees": "identity", "vat": "voteAccount" } } Request: { "identity": string | null, "voteKey": string | null, "limit": u64 } // 1..=100, else 400 Response: array ordered oldest-first over the inclusive range [current_epoch - limit, current_epoch], one item per epoch that has an income row (epochs without one are absent, same as /epochs/income). Sources: - blockRevenue, rates: the `em`/`ei` rows behind /epochs/income. rates are the EFFECTIVE rates for the epoch (SIMD-0249), so income.blockRevenueCommission is what actually paid out. - income.voteAccountReward: what the first block of epoch E+1 paid the vote account for epoch E (`em.ir`, written by the ingestor; the same entries getInflationReward reads), summed across an identity's vote accounts. Under tower that is the inflation commission, and inflationCommission repeats it. Under alpenglow the same payout also contains the validator's own participation reward and the block does not separate the two, so inflationCommission is null. The current epoch is always null; null on a past epoch means not backfilled yet. `net` uses voteAccountReward. - income.votingReward / income.producerBonus (Alpenglow epochs only; null under tower or before the epoch is ingested): what the vote account EARNED, gross and before the commission split, from its own votes and as a block producer (half of every vote it packed). The same numbers as /epochs/participation rewards.votingLamports / producerBonusLamports, so they move during the epoch while voteAccountReward appears only after it. Their sum is what agave credited the vote account; the epoch-boundary payout then gives the vote account its commission share (voteAccountReward) and stakers the rest. NOT part of `net` (that would count the payout twice); chart them as the gross breakdown behind voteAccountReward. producerBonus is null for epochs ingested before it was tracked. - costs.voteFees (Tower): votedSlots × 5000 lamports — one vote transaction per landed slot at the base fee. An UPPER BOUND: a validator that batches slots into one transaction pays less. Hence voteFeesExact: false. Justin's call: no exact column, since the fee disappears with Alpenglow. - costs.vat (Alpenglow): the ticket the runtime debited for this epoch (`em.vat`). It is debited in the first block of the PREVIOUS epoch, when the runtime builds this epoch's stake set, so an epoch's vat is known one epoch ahead. Until the row has it, the slot-tier constant (1.0 SOL at 250 ms) with vatExact false. - rates.inflationRewardsCommissionBps comes from the same Voting reward entry as inflationCommission (the rate the runtime applied), so it is exact for every epoch the ingestor or backfill has seen. - consensus: tower until the alpenglow feature's activation epoch, then alpenglow (the activation epoch counts as alpenglow). Gotchas: - startTime/endTime come from our ingested block times (bm.time) of the first and last confirmed block within 64 slots of the boundary; null if those blocks are not ingested. Use endTime (or now, for the current epoch) to price an epoch in fiat at its own time. - net excludes hardware and everything else off chain. - Missing rates (older rows, before SIMD-0291/0123 data existed) are treated as 100% to the validator, which is what the runtime did. - rates.mevCommissionBps is the commission in the epoch's Jito tip-distribution account, as Jito's on-chain ValidatorHistory recorded it (`em.cmm`, written by the block-ingestor at each epoch boundary). It is the same for every client that pays through Jito's tip programs (Jito-Solana, BAM, Harmonic, Rakurai, Firedancer bundles). null when the validator ran no such client that epoch, or the epoch is not settled yet (rows are written about one epoch after it ends). - mevCommission applies that rate to gross mevTips; with a null rate it is the whole of mevTips. Before em.cmm existed every epoch reported the whole of mevTips; once an epoch's rate is written (including by the backfill), the same epoch reports only the validator's share. Clients that cached old values should refetch. Gross tips are before the block-builder and tip-router fees Jito deducts, so it slightly overstates the validator's share. ---------------------------------------------------------------- GET|POST /epochs/vote-stats ---------------------------------------------------------------- Per-epoch voting performance for one VOTE account (note: voteKey, not node identity) over the last N epochs. $ curl -s 'https://api.vx.tools/epochs/vote-stats?voteKey=Vote...&limit=5' \ | jq '.[0]' $ curl -sX POST https://api.vx.tools/epochs/vote-stats \ -H 'content-type: application/json' \ -d '{"voteKey": "Vote...", "limit": 5}' | jq '.[0]' { "epoch": 970, "votedSlots": 429000, "earnedCredits": 1500000, "totalLatency": 880000, "totalExcessLatency": 12000, "perfectSlots": 400000, "okSlots": 25000, "slowSlots": 4000, "skippedSlots": 3000, "lastUploadedBucket": 432000, "ranking": 1 } Request: { "voteKey": string, "limit": u64 } // 1..=100, else 400 Response: array ordered oldest-first over the inclusive range [current_epoch - limit, current_epoch] (`limit + 1` epochs when every row exists; missing epochs are absent). Notes: - Tower-era data only. The window counts back from the last Tower epoch (`/epochs/current` `alpenglow.cutoverEpoch - 1` once Alpenglow is active; the current epoch otherwise), so "last N" stays N epochs with data. Alpenglow epochs never appear in the array. - `perfectSlots` = votedSlots - okSlots - slowSlots (server-derived). - `skippedSlots` = idealVoter.votedSlots - votedSlots (server-derived; the server fetches the ideal-voter row in the same request). - `totalExcessLatency` is skip-aware: 0 means "not yet populated", not "perfect performance". Combine with `lastUploadedBucket` to detect in-progress backfills. - Sentinel for not-yet-classified rows: `okSlots == 0 && slowSlots == 0 && votedSlots > 0` — older epochs that haven't been classified yet. ---------------------------------------------------------------- GET|POST /epochs/votes ---------------------------------------------------------------- Base64-encoded per-slot vote history for a validator in one epoch. Accepts EITHER an `identity` (node) OR a `voteKey` (vote account); if both supplied, `voteKey` wins. $ curl -s 'https://api.vx.tools/epochs/votes?identity=Foo...&epoch=969' $ curl -sX POST https://api.vx.tools/epochs/votes \ -H 'content-type: application/json' \ -d '{"identity": "Foo...", "epoch": 969}' { "epoch": 969, "consensus": "tower", "votesBase64": "AAAAAAEAAAA..." // null if epoch un-uploaded } Request: { "identity": string | null, "voteKey": string | null, "epoch": u64 | null } // default: current epoch Response: { "epoch": u64, "consensus": "tower" | "alpenglow", "votesBase64": string | null } Errors: 404 with `votesBase64: null` when the row hasn't been ingested, and always for `consensus: "alpenglow"` epochs (>= `cutoverEpoch` from /epochs/current): Alpenglow votes are not transactions, so there is no per-slot history to serve. Cap epoch pickers at `cutoverEpoch - 1`. The base64 payload is truncated to (completedBuckets * BUCKET_SIZE) bytes — trailing zero buckets are not transmitted. ---------------------------------------------------------------- GET|POST /epochs/leaderboard (alias) GET|POST /epochs/leaderboard/voting ---------------------------------------------------------------- Voting performance leaderboard for one epoch. The two routes are identical; /epochs/leaderboard is kept for backwards compat. Tower-era product, frozen at the Alpenglow cutover: for epochs >= `cutoverEpoch` (see /epochs/current) the response is `{ "epoch", "consensus": "alpenglow", "records": [] }` and no storage is read. Tower epochs carry `"consensus": "tower"`. Alpenglow-era metrics are a separate product on separate endpoints. $ curl -s 'https://api.vx.tools/epochs/leaderboard/voting?epoch=969' \ | jq '.records[0:2]' $ curl -sX POST https://api.vx.tools/epochs/leaderboard/voting \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' { "epoch": 969, "consensus": "tower", "records": [ { "nodeName": "(optimal record)", "nodeAddress": "111...", "voteAddress": "111...", "votedSlots": 432000, "earnedCredits": 1700000, "totalLatency": 864000, "totalExcessLatency": 0, "perfectSlots": 432000, "okSlots": 0, "slowSlots": 0, "skippedSlots": 0, "rank": 0, "datacenter": null, "continent": null, "country": null, "provider": null, "softwareBase": null, "softwareFlavor": null, "softwareVersion": null }, { "nodeName": "Acme", "nodeAddress": "Foo...", "voteAddress": "Vote...", "votedSlots": 429000, ... "rank": 1, "datacenter": "20473-US-Atlanta", "continent": "North America", "country": "United States", "provider": "Vultr", "softwareBase": "Agave", "softwareFlavor": "Jito", "softwareVersion": "4.3.0" } ] } Request: { "epoch": u64 | null } // default: current epoch Notes: - `rank` is competition rank by `earnedCredits` among real validators, starting at 1: ties share a rank and the next distinct record jumps past the tie group (1, 2, 2, 4, 5, ...). The server pre-computes it; don't re-derive from response position. - The first record is always the synthetic "(optimal record)" row with `rank: 0`. It is a cluster-wide ideal-voter baseline, NOT a real validator. Filter by `nodeAddress == "11111111111111111111111111111111"` before ranking real validators. - See gotchas section for cache TTLs, slot derivation, provider semantics. ---------------------------------------------------------------- GET|POST /epochs/participation ---------------------------------------------------------------- Alpenglow successor to /epochs/vote-stats: per-epoch certificate participation for one VOTE account over the last N epochs, from the cutover epoch on. Votes are BLS messages in Alpenglow, never transactions, so there are no credits or latencies here; what the chain records per slot is who was in the notarize / skip *reward certificate* bitmaps of the footer eight slots later, and who was in the finalization aggregate the leader carried. Every count is over `eligibleSlots` (slots whose footer our warehouse read), never over the epoch's slot count. $ curl -s 'https://api.vx.tools/epochs/participation?voteKey=Vote...&limit=5' \ | jq '.[0]' { "epoch": 1049, "admitted": true, "stakeRank": 17, "stake": 5000000000000, "eligibleSlots": 431000, "notarizedSlots": 430000, "skippedSlots": 900, "skippedNotarizedSlots": 850, "missedSlots": 100, "finalizedCerts": 20000, "finalizedSlots": 19000, "fastFinalizedSlots": 18500, "lastIngestedSlot": 453599999, // null until anything is ingested "complete": true, "missingFooters": 0, "rank": 12, // this epoch's leaderboard rank "rewards": { // null: not admitted / nothing ingested / budget unknown "perSlotLamports": 4556809, // one paid slot, its half "votingLamports": 1963983079, // earned by its notarize + skip votes "missedLamports": 455680900, // lost to its missed slots (unpaid) "unpayableLamports": 9113618, // slots that paid nobody: not its fault "producerBonusLamports": 51200000, // null until ingested with this accounting "totalLamports": 2015183079, // voting + producer bonus; null with it "maxVotingLamports": 1968541488000,// every slot of the epoch "onChainLamports": 2015190000 } // vote account epoch_credits gain; null past 5 epochs } Request: { "voteKey": string, "limit": u64 } // 1..=100, else 400 Response: array ordered oldest-first over [max(cutoverEpoch, current_epoch - limit), current_epoch + 1]; epochs without a row are absent. The current_epoch + 1 item appears once next epoch's rank map is synced (early in the current epoch): admission only — admitted, stakeRank, stake — with every counter 0, lastIngestedSlot null and complete false. Use it for "admitted next epoch", leave it out of performance charts. Empty while Alpenglow is not active. Fields: - rank: the validator's `rank` on that epoch's /epochs/leaderboard/participation (competition rank by participation, 0-based, ties shared), so a per-epoch table needs no leaderboard download. null when not admitted or nothing is ingested yet (the current + 1 admission row). The current epoch's rank moves as slots ingest (recomputed at most once a minute); a complete epoch's is fixed. - admitted: in the epoch's rank map, i.e. able to vote at all (BLS key registered, in the top-2000 by stake, above the minimum balance, unique node identity). false rows have zero counters and stakeRank null. - stakeRank: position in the rank map, 0 = most stake. Not a performance rank. - notarizedSlots / skippedSlots / missedSlots: eligible slots where the validator voted notarize, voted skip, or cast neither vote (missed = eligible - notarized - skipped). A skip is a real vote and pays the same as notarize; a missed slot pays nothing. NOTE the Tower endpoints use `skippedSlots` for "did not vote", which is this product's `missedSlots`; here `skippedSlots` means skip votes cast. - skippedNotarizedSlots: of skippedSlots, slots the cluster notarized anyway (a block exists at that slot). A skip vote on a slot everyone else notarized is a vote that arrived too late or saw the wrong side; the lag / free-rider signal. - finalizedSlots / fastFinalizedSlots: finalization certificates the validator's vote appears in, and how many were the one-round 80 % fast path. Denominator finalizedCerts, the certificates decoded for the epoch (epoch-wide). Leader-sampled, since each footer carries the highest certificate its leader knew. - lastIngestedSlot / complete: the last reward slot of the epoch ingested (the ingestor streams slot by slot, a few seconds behind the chain, and writes these rows about every 25 s; per-slot rows, /slots and /slot, every ~2 s); complete once it is the epoch's last slot. lastIngestedSlot is null until the first write: admission (admitted, stakeRank, stake) is written a whole epoch ahead, when the rank map for the epoch is captured at the start of the previous one, so the NEXT epoch's row already says who will be able to vote, with every counter at 0. - missingFooters: footer slots our ledger copy had no row for that the parent chain did not mark as skipped (epoch-wide). Those slots are not eligible, so rates stay honest; the number tells you how much the sample misses. - rewards: what the validator's votes earned, in lamports, over the reward slots ingested so far. Alpenglow pays each vote in a reward certificate budget x stake / (slotsPerEpoch x totalStake) (the epoch's inflation, the rank map's stake), half to the voter and half to the producer of the footer that carried it. So: perSlotLamports one paid slot for this validator (its half) votingLamports (notarizedSlots + skippedSlots) x perSlot — a late skip pays exactly like a notarize missedLamports missedSlots x perSlot: what it missed out on unpayableLamports reward slots that paid NOBODY (the footer eight slots later carried no reward certificate, e.g. that block was skipped) x perSlot: not its fault producerBonusLamports its half of every vote in the reward certificates of the footers it produced (null for epochs ingested before this was tracked) totalLamports votingLamports + producerBonusLamports maxVotingLamports slotsPerEpoch x perSlot: every slot of the epoch onChainLamports the vote account's epoch_credits gain, which holds lamports under Alpenglow: what agave credited, voting and bonus together; null once RPC stops reporting the epoch (5 back) "Missed out on X (y % of max)" = missedLamports / maxVotingLamports. Agave credits a vote in its FOOTER's epoch, so onChainLamports includes the previous epoch's last 8 slots and excludes this one's; otherwise totalLamports and onChainLamports agree (checked on testnet epoch 1051: cluster total to 1.6e-7, non-producers to the lamport). Pre-commission: the vote account earns it, stakers are then paid by commission. Rates: participation = (notarizedSlots + skippedSlots) / eligibleSlots; notarized = notarizedSlots / eligibleSlots; lag = skippedNotarizedSlots / eligibleSlots; finalized = finalizedSlots / finalizedCerts. ---------------------------------------------------------------- GET|POST /epochs/leaderboard/participation ---------------------------------------------------------------- Alpenglow successor to /epochs/leaderboard/voting: every validator's participation for one epoch, with the same cohort fields (datacenter, provider, software) as the voting leaderboard. Tower epochs (< cutoverEpoch) return `"consensus": "tower"` and empty records without a storage read. $ curl -s 'https://api.vx.tools/epochs/leaderboard/participation?epoch=1049' \ | jq '{epoch, eligibleSlots, admitted, records: .records[0:1]}' { "epoch": 1049, "consensus": "alpenglow", "eligibleSlots": 431000, "finalizedCerts": 20000, "lastIngestedSlot": 453599999, "complete": true, "missingFooters": 0, "admitted": 469, "rewardTotals": { // null: budget unknown / nothing ingested "budgetLamports": 393813701191034, // the epoch's inflation, both halves "votingLamports": 190000000000000, "missedLamports": 1000000000000, "unpayableLamports": 2600000000000, "producerBonusLamports": 190000000000000, // null while any row lacks it "maxVotingLamports": 196900000000000 }, "records": [ { "nodeName": "Acme", "nodeAddress": "Foo...", "voteAddress": "Vote...", "admitted": true, "stakeRank": 17, "stake": 5000000000000, "eligibleSlots": 431000, "notarizedSlots": 430000, "skippedSlots": 900, "skippedNotarizedSlots": 850, "missedSlots": 100, "finalizedCerts": 20000, "finalizedSlots": 19000, "fastFinalizedSlots": 18500, "lastIngestedSlot": 453599999, "complete": true, "missingFooters": 0, "rank": 0, "rewards": { ... }, // as /epochs/participation; null when not admitted "datacenter": "20473-US-Piscataway", "continent": "North America", "country": "United States", "provider": "Vultr", "softwareBase": "Agave", "softwareFlavor": "jito", "softwareVersion": "4.3.0" } ] } Request: { "epoch": u64 | null } // default: current epoch Response: { "epoch", "consensus", "eligibleSlots", "finalizedCerts", "lastIngestedSlot", "complete", "missingFooters", "admitted": u64, // rows with admitted == true "rewardTotals": { ... } | null, // sums over admitted records' rewards "records": [ ... ] } // best participation first Notes: - rank: competition rank by participation rate (notarized + skipped) / eligible, ties broken by notarizedSlots, exact ties shared (0, 1, 1, 3, …). null for validators not admitted to the epoch; they follow the ranked records, ordered by stake. Authoritative; do not re-derive from position. - The epoch-wide fields are copied from the rows (every row carries the same values) and are 0 with `lastIngestedSlot: null`, `complete: false` while nothing has been ingested yet — which is the state of the next epoch's leaderboard from the moment its rank map is captured: all admission, no counts. - Record fields are those of /epochs/participation plus nodeName, nodeAddress, voteAddress, rank and the cohort fields documented on /epochs/leaderboard/voting. nodeName/nodeAddress are "N/A" when the validator directory does not know the vote account. ---------------------------------------------------------------- GET|POST /epochs/participation/history ---------------------------------------------------------------- Alpenglow successor to /epochs/votes: what one VOTE account did on every reward slot of an epoch, from the per-validator timeline the Alpenglow ingestor writes. $ curl -s 'https://api-testnet.vx.tools/epochs/participation/history?voteKey=Vote...&epoch=1050' { "epoch": 1050, "consensus": "alpenglow", "voteKey": "Vote...", "firstSlot": 448076256, // outcome i is for slot firstSlot + i "slots": 11000, // up to the epoch's lastIngestedSlot "outcomesBase64": "VVVVVVVV...", // null for Tower epochs / unknown validators "notarized": 10591, "skipped": 200, "missed": 0, "ineligible": 209 } Request: { "voteKey": string, "epoch": u64 | null } // default: current epoch Encoding of outcomesBase64: 2 bits per reward slot, little-endian within each byte — slot i is bits 2·(i mod 4) .. +1 of byte i / 4: 0 not eligible (no footer / no reward certificate for that slot) or not ingested yet 1 notarized (in the notarize reward bitmap) 2 skipped (in the skip reward bitmap: a real vote, paid like notarize) 3 missed (eligible slot, in neither bitmap) Decode in JS: b = atob(...); code = (b.charCodeAt(i >> 2) >> ((i & 3) * 2)) & 3. Only admitted validators have timelines; a not-admitted vote key returns outcomesBase64 null with all counts 0. Grows as the ingestor writes (about every 25 s; /slots is fresher, ~2 s); `slots` says how far. ---------------------------------------------------------------- GET|POST /alpenglow/rank-map ---------------------------------------------------------------- The per-epoch validator ordering that Alpenglow certificate bitmaps index: rank → vote account, node identity, stake, BLS key. Built from the frozen stake set the runtime fixes at the first slot of the previous epoch (our validators serve it over the fork RPC method getEpochStakes); verified against real certificates before the ingestor attributes anything with it. Also the authoritative "admitted" list and why the others are not. $ curl -s 'https://api-testnet.vx.tools/alpenglow/rank-map?epoch=1050' | jq '{epoch, source, verification, totalStake, ranks: .ranks[0:2], notAdmitted: .notAdmitted[0:2]}' { "epoch": 1050, "consensus": "alpenglow", "source": "epoch-stakes-rpc:http://127.0.0.1:8899@448076300", "capturedSlot": 448076300, "verification": { "status": "verified", "slot": 448076520, "certificates": 50, "message": null }, "totalStake": 357389724000000000, "ranks": [ { "rank": 0, "voteAddress": "Vote...", "nodeAddress": "Node...", "stake": 43783659000000000, "blsPubkey": "base64 48 bytes" }, ... ], "notAdmitted": [ { "voteAddress": "Vote...", "nodeAddress": "Node...", "stake": 303514790116970, "reason": "below_minimum_balance" }, ... ] } Request: { "epoch": u64 | null } // default: current epoch; 404 if no map stored Notes: - ranks are sorted by stake descending, ties by compressed BLS key, the runtime's rule; `rank` is the bitmap position. - verification.status: "verified" means 50+ real aggregates checked out against this map; "unverified" means the epoch has no footers yet (the map for E exists from the start of E-1); "failed" means the stored map is wrong and is being replaced — do not decode bitmaps with it. - notAdmitted reasons: no_bls_key, zero_stake, below_minimum_balance (vote account under rent + the admission ticket; only derivable for unfiltered captures), duplicate_node_identity, duplicate_bls_key, beyond_cap (past the 2000-validator cut), not_in_stake_set (staked at capture but absent from the frozen set, which the runtime had already VAT-filtered — almost always a vote-account balance below the minimum at the freeze slot; `stake` is then the live stake seen at capture). not_in_stake_set entries exist only for maps synced during their own epoch or the one before (older maps have no live set to compare with). - stakes are JSON numbers; mainnet totals exceed 2^53 — do not do arithmetic on them in JavaScript without BigInt. - Immutable once verified: cache per epoch. ---------------------------------------------------------------- GET|POST /slots/footer ---------------------------------------------------------------- One block's footer, decoded, with the raw material to re-verify every claim. The footer in block S carries the reward certificates for slot S − 8 (who notarized, who skipped) and the highest finalization certificate the leader knew. "Did my vote land in slot N?" is this endpoint for slot N + 8. $ curl -s 'https://api-testnet.vx.tools/slots/footer?slot=448076264' { "slot": 448076264, "epoch": 1050, "consensus": "alpenglow", "parentSlot": 448076263, "updateParent": false, "leader": "Node...", "userAgent": "agave/4.3.0 (...)", "producerTimeNanos": 1791000000000000000, "rewardSlot": 448076256, "finalizationPath": "fast", "finalizedSlot": 448076263, "certificates": [ { "kind": "notarizeReward", "slot": 448076256, "rankMapEpoch": 1050, "signers": [0, 1, 2, 5, ...], "signerVoteAddresses": ["Vote...", ...], "stakePpb": 987654321, "signatureBase64": "...", "bitmapBase64": "..." }, { "kind": "skipReward", "slot": 448076256, ... }, { "kind": "fastFinalization", "slot": 448076263, ... } ], "verification": "verified", // the ingestor's BLS verdict for this footer "shredVersion": null, "rawMarkersBase64": "..." // the ledger's block-markers cell, as stored } Request: { "slot": u64 } // the slot whose BLOCK carries the footer; 404 if no footer row Notes: - certificates[].kind: notarizeReward | skipReward | fastFinalization | finalization | finalizationNotarize (the 60 % notarize aggregate carried with a slow-path finalization). - signers / signerVoteAddresses are our decoding with that epoch's verified rank map (empty if none is verified yet); signatureBase64 and bitmapBase64 are the proof: aggregate the rank map's BLS keys of the set bits and verify the signature over the vote message (kind, slot, block id, cluster shred version) — a leader can omit votes but cannot forge a signer, the signature would fail. - rawMarkersBase64 is the exact `block-markers` cell (compressed bincode of the marker byte vectors) for people who decode themselves. - Immutable once written: cache per slot. ---------------------------------------------------------------- GET|POST /slots ---------------------------------------------------------------- What happened on each slot of a range, one item per slot, keyed by the slot it describes (no +8 arithmetic): the outcome, the leader, the votes on it, its own footer, and when it was finalized. Skipped slots are included. ~400 bytes per slot; /slots/footer has the full certificate proof for one. $ curl -s 'https://api-testnet.vx.tools/slots?startSlot=448220656&endSlot=448220660' { "startSlot": 448220656, "endSlot": 448220660, "slots": [ { "slot": 448220656, "outcome": "block", // block | skipped | noData; null = not ingested yet "leader": "Node...", "votes": { // from footer slot + 8; null until it is ingested "notarStakePpb": 840000000, "skipStakePpb": 150000000, "signers": 452, "verification": "verified" }, "footer": { // this block's own footer; null for skipped slots "producerTimeNanos": 1791065405756110376, "userAgent": "agave/4.4.0-beta.0 (...)", "updateParent": false, "carriedFinalizationPath": "fast", // the certificate this footer carried "carriedFinalizedSlot": 448220655, "carriedFinalizationStakePpb": 912000000, "verification": "verified" }, "finalization": { // null until a certificate reaches it "byFooterSlot": 448220658, // first footer whose certificate covers this block "lagSlots": 2, "lagMs": 810, "directPath": null, // fast | slow when this block got its own certificate "directSource": null, // footer | live: where directPath comes from. Footers // carry only the highest certificate their leader knew, // so they skip blocks finalized on their own; "live" = // the block's own certificate from the recorded vote // pools (observer start onward). null: not known "directStakePpb": null } }, ... ] } Request: { "startSlot": u64, "endSlot": u64 } // inclusive, at most 1000 slots; 400 otherwise Notes: - votes are what the leader of slot + 8 had received when it built its block: a lower bound on votes cast. null stake = no aggregate of that kind (no such votes seen). notar + skip <= 100 %; the rest is "not in the certificates" (didn't vote, late, unseen, other block version). - finalization: the warehouse holds the finalized chain, so a certificate for slot f finalizes every block up to f; the lag is to the first footer carrying one that reaches this block. lagMs uses the two blocks' producer times. - Slots fill in over a few seconds (outcome and footer first, votes at +8, finalization a few slots later). Cache a range once its last slot is older than ~1 minute. ---------------------------------------------------------------- GET|POST /slot ---------------------------------------------------------------- Everything about one slot in one call, Tower or Alpenglow: the explorer's slot page. $ curl -s 'https://api-testnet.vx.tools/slot?slot=448220656' { "slot": 448220656, "epoch": 1050, "slotIndex": 144400, "consensus": "alpenglow", // per slot: tower through G, alpenglow after "leader": "Node...", "leaderWindow": { "firstSlot": 448220656, "position": 1, "slots": 4 }, "blockTime": 1791065405, // where the block-ingestor runs (mainnet) "income": { "baseFees": 0, "priorityFees": 0, "mevTips": 0 }, // idem "tower": null, // Tower slots: isSkipped, noVotes, latencyDistribution "alpenglow": { ...one /slots item: outcome, leader, votes, footer, finalization... }, "missingVoters": [ // admitted, in neither reward bitmap of footer slot + 8 { "voteAddress": "Vote...", "nodeAddress": "Node...", "stake": 28304171219295247 }, ... ], "missingStakePpb": 160000000, "pool": { // our nodes' vote pools when the slot settled; null = not recorded "nodes": 1, // nodes merged "partial": false, // true: some votes may be missing; absence here is unknown "notarize": [ { "blockId": "...", "voters": 455, "stakePpb": 974375274, "firstMs": 0, "lastMs": 410, "parentSlot": 448973150, "parentBlockId": "..." } ], // more than one = votes split between versions "notarizeFallback": [], "skip": { ... } | null, "skipFallback": null, "finalize": { "blockId": null, "voters": 450, "stakePpb": 970000000, ... } | null, "receivedNotIncluded": { "voters": 3, "stakePpb": 2100000, "validators": [ { "voteAddress", "nodeAddress", "stake" } ] } | null, "afterOurRoot": { ... } | null, "certificates": [ { "kind": "fastFinalize", "blockId": "...", "formedMs": 380 } ] }, "producerRecord": { ...this leader's /epochs/producers record... }, "note": null // why parts are empty, when they are } Request: { "slot": u64 } Notes: - tower (Tower slots): { isSkipped, noVotes, latencyDistribution } as /blocks/insights. alpenglow / missingVoters (Alpenglow slots). - missingVoters is null until footer slot + 8 is ingested, or when that footer carried no reward certificates (slot not eligible). "Missing" means "not in the certificates the leader of slot + 8 included": did not vote, voted late, or the vote did not reach that leader. Group it by region / provider / client with /validators. - pool: consensus data for the slot beyond what footers carry, gathered live from our validators' vote pools (an extra source, not the subject: the voter record is still footer slot + 8, i.e. `alpenglow.votes` and missingVoters). Use it for what footers cannot show: the block versions voted for (more than one = a split), fallback votes, the certificates and when they formed, and finalize votes. On nodes running agave's late-vote window (`--consensus-pool-late-vote-window 32`), votes that arrive after the slot is rooted are verified and kept too, so the sets are every vote received; without it a pool keeps only the votes before the root (~80 % of stake with fast finality, finalize votes mostly absent). During a stall or a contested slot nothing is rooted and it is complete either way. receivedNotIncluded / afterOurRoot compare it with the footer and are diagnostics, not validator metrics: do not rank or headline them, and do not present per-validator vote timing from here. null when not recorded; partial when a node restarted mid-slot. - Cache once the slot is older than ~1 minute. ---------------------------------------------------------------- GET|POST /epochs/producers ---------------------------------------------------------------- Every block producer's record in an Alpenglow epoch so far (each node the leader schedule names): how its 4-slot windows went, its blocks' finalization lag, when it leads next. Worst first (most windows with skips). E.g. a producer that is offline shows skippedWindows == windows. "Leader" in this API is the protocol's schedule (`leader` fields name the scheduled node); "producer" is how that node did at producing blocks. Ranked, with rewards and vote inclusion: /epochs/leaderboard/producers. $ curl -s 'https://api-testnet.vx.tools/epochs/producers?epoch=1051' | jq '.records[0]' { "nodeAddress": "DtSguGSH...", "windows": 187, // windows wholly ingested so far "producedWindows": 0, // every slot a block "partialWindows": 0, // some slots skipped "skippedWindows": 187, // no block at all "blocks": 0, "skippedSlots": 748, "meanFinalizationLagSlots": null, // over its blocks "remainingWindows": 112, // still to come this epoch "nextWindowSlot": 448810312 } // null when none left Request: { "epoch": u64 | null, // default: current epoch "producer": string | null } // one node identity Response: { "epoch", "lastIngestedSlot", "records": [ ... ] }; 404 when the epoch has no stored leader schedule. Cached a minute for the current epoch. /slot carries the slot's producer's record as `producerRecord`. ---------------------------------------------------------------- GET|POST /epochs/leaderboard/producers ---------------------------------------------------------------- Block-producer leaderboard for one Alpenglow epoch: every node the leader schedule names, with its /epochs/producers window record, what its blocks did for consensus and rewards, and the cohort fields of the participation leaderboard. (Tower epochs have no such data: 404 without a stored leader schedule; the Tower view of block production is /epochs/leaderboard/income.) $ curl -s 'https://api-testnet.vx.tools/epochs/leaderboard/producers?epoch=1052&sortBy=inclusion' \ | jq '{cluster, records: .records[0:1]}' { "epoch": 1052, "lastIngestedSlot": 449100000, "sortBy": "inclusion", "order": "desc", "cluster": { "nodeName": "(cluster)", "nodeAddress": "1111...", ...sums, as a record... }, "records": [ { "nodeName": "Acme", "nodeAddress": "Node...", "voteAddress": "Vote...", "stake": 5000000000000, "rank": 0, // for the requested sort; null = nothing to rank yet "windows": 112, "producedWindows": 111, "partialWindows": 1, "skippedWindows": 0, "blocks": 447, "skippedSlots": 1, "meanFinalizationLagSlots": 1.6, "remainingWindows": 75, "nextWindowSlot": 449101232, "skipRate": 0.0022, // skippedSlots / (blocks + skippedSlots) "payableSlots": 448, // its leader slots that could carry votes (block or skipped) "effectiveInclusion": 0.9845, // Σ included stake share / payableSlots; skipped = 0 "rewardCerts": 446, // its footers carrying reward certificates "meanIncludedStakePpb": 991000000, // stake those certificates included, mean "producerBonusLamports": 2030000000,// its half of every vote in them; null: epoch // ingested before this was tracked "certifiedBlocks": 401, // its blocks a footer's certificate named directly "fastFinalizedBlocks": 399, // of those, fast path (80 %) "datacenter": "...", "continent": "...", "country": "...", "provider": "...", "softwareBase": "Agave", "softwareFlavor": "jito", "softwareVersion": "4.4.0" } ] } Request: { "epoch": u64 | null, // default: current epoch "sortBy": "effectiveInclusion" (default) | "skipRate" | "inclusion" | "bonus" | "fastFinalization" | "finalizationLag" | "stake", "order": "asc" | "desc" | null } // default: asc for skipRate and // finalizationLag, desc otherwise Notes: - effectiveInclusion is the ranking: over its payable leader slots (each slot s + 8 it led, whether it produced a block or skipped it), the average stake share its reward certificates included for slot s, a skipped slot counting 0. 1.0 = every vote of the cluster in every slot. It equals the producer bonus normalized by the most it could have earned, without the budget's rounding. Numerator and denominator are counted by the ingestor over the same slots (attributed, like the bonus, to the reward slot's epoch), so it stays in [0, 1] at the tip. Which leader slots count (payableSlots): every slot s + 8 it led, block or skipped. A leader can always carry a certificate for s: if s itself was skipped, its skip votes are included and paid like notarize votes, so there is no "nothing to include" case. Its own skipped slots count 0 (it was the one that could have carried them). Left out, in both numerator and denominator: warehouse gaps (unknown whether it produced), and in the cutover epoch the first 8 Alpenglow slots, whose s is a Tower slot no certificate pays for. Small samples (a few windows) are noisy. Needs an epoch ingested with this counter (null before). - meanIncludedStakePpb is the producer's vote collection: of the stake that could vote, how much its reward certificates (in its footers, for the slot eight before) carried. Low inclusion is partly connectivity, not necessarily intent; it also costs the producer, whose bonus is half of each included vote. - producerBonusLamports is credited to `voteAddress`: the node's highest-stake vote account in the epoch's stake set, as agave pays the leader's vote account. Pre-commission. - fastFinalized / certifiedBlocks: most blocks are finalized by a later certificate (the lag); certifiedBlocks counts those whose own certificate a footer carried, the sample the fast-path share is over. Absolute counts therefore undercount; use the share. Per slot, /slots finalization.directSource "live" fills in blocks the footers skipped. - cluster sums every record's counts; its skipRate, meanIncludedStakePpb and meanFinalizationLagSlots are over all slots / certificates / blocks (not a median of records). nodeAddress is the all-zero address. - Cached a minute for the current epoch (the window records); the rest is read per request. ---------------------------------------------------------------- GET|POST /consensus/live ---------------------------------------------------------------- What consensus is doing right now, from our validators' vote pools (every node that may hold the staked identity, polled every second and merged by the consensus observer), including slots that never finalize. Use it to watch a slowdown or stall as it happens: who has not voted on an in-flight slot, votes split between block versions, certificates forming late. Everything else in this API is built from finalized blocks. $ curl -s 'https://api.vx.tools/consensus/live?limit=8' { "available": true, // false: no observer configured / reachable, or no node answered "stale": false, // snapshot older than 10 s: the nodes (or observer) stopped answering "blind": false, // none of our nodes answers: no slots, see publicFinality "nodes": [ { "label": "node1", "ok": true, "identity": "Node...", "voting": true }, { "label": "node2", "ok": true, "identity": "Spare...", "voting": false } ], // voting: null until the nodes run the v2 pool RPC "publicFinality": null, // while blind: { finalizedSlot, processedSlot, finalizedStillSecs } "snapshotAgeMs": 840, "rootSlot": 453547500, "highestFinalizedSlot": 453547560, "highestNotarizedSlot": 453547566, "highestSkipCertifiedSlot": 453547550, "standstill": false, "finalityGapSlots": 6, // highest slot with votes − highest finalized "slots": [ // newest first { "slot": 453547566, "epoch": 1049, "leader": "Node...", "status": "voting" | "notarized" | "finalized" | "skipped", "split": false, // notarize votes for more than one block version "notarize": [ { "blockId": "...", "stakePpb": 812345678, "voters": 541, "firstMs": 0, "lastMs": 380, "parentSlot": 453547565, "parentBlockId": "..." } ], // parent*: when one of our nodes holds that version // (null for a version known only by votes); with // two versions, draws the split as a tree "notarizeFallback": [], "skip": null, "skipFallback": null, "finalize": { "blockId": null, "stakePpb": 640000000, "voters": 410, "firstMs": 90, "lastMs": 400 }, "certificates": [ { "kind": "notarize", "blockId": "...", "stakePpb": 612000000, "formedMs": 120, "source": "local" } ], "notVoted": { "voters": 61, "stakePpb": 140000000, "validators": [ { "voteAddress": "...", "nodeAddress": "...", "stake": 123 } ] } } ] } Request: { "limit": u64 | null, // most recent slots, default 32, max 256 "slot": u64 | null } // one slot in detail: every not-voted validator, plus // each vote set's bitmapBase64 (base-2 over the rank map) Notes: - How far back: the node keeps only slots not yet rooted (it prunes below the root bank as the root advances). Normally that is a handful of slots, a few seconds: expect 3-8 items, mostly voting / notarized; a slot disappears about a second after it is finalized. When finality slows or stops, nothing is pruned and the list grows to every slot since the last finalized one (the newest 128 here). For a finished slot use /slot (footer-based, authoritative); this endpoint is for slots in flight. - Our nodes' view: votes reach the staked node all-to-all, so it is close to the cluster's, but a validator cut off from it only looks like it has not voted. Around a hot-spare switch each node holds part of the votes; the view is their union, so a switch shows no hole. - *Ms are offsets from the first vote or certificate this node saw for the slot, not from slot start. Per-validator arrival times are not available (the pool receives pre-aggregated votes): firstMs / lastMs per vote kind only. - notVoted: admitted validators in none of the slot's vote sets YET (the slot is still in flight). Group with /validators. - The observer sends the alerts (once each, then "recovered"): finality stalled (finalized slot still for 20 s while slots 32+ past it are voted on, or standstill); blind (no node answering for 30 s, finality then watched from the public RPC); none of our nodes voting; two nodes voting with one identity. Finished slots keep their pool: see /slot `pool`. - Do not cache; poll every few seconds. ---------------------------------------------------------------- GET|POST /epochs/cluster/history ---------------------------------------------------------------- What the chain did on every slot of an Alpenglow epoch, aligned slot for slot with /epochs/participation/history (same firstSlot, same packing), so one call per epoch serves every validator page. $ curl -s 'https://api-testnet.vx.tools/epochs/cluster/history?epoch=1050' { "epoch": 1050, "consensus": "alpenglow", "firstSlot": 448076256, "slots": 225000, "lastIngestedSlot": 448301255, "outcomesBase64": "VVVVVVVV...", // null for Tower epochs / nothing ingested "finalizationLagBase64": "CAcABQQ...", // 1 byte per slot, same indexing "blocks": 222100, "skipped": 2890, "noData": 0, "unknown": 10 } Request: { "epoch": u64 | null } // default: current epoch Encoding: 2 bits per slot, little-endian within each byte, as for the validator timeline: 0 unknown (not ingested yet) 1 block (a block for the slot is in the chain) 2 skipped (the chain passed over the slot: no block for it) 3 no data (the slot is missing from our ledger warehouse; not a cluster outcome) finalizationLagBase64: one byte per slot, slots from the block to the first footer whose finalization certificate reached it (0 = not a block or not known yet, 255 = 255 or more). Typically 1-3; a run of large values is a slow-finality stretch. Combining with a validator's timeline (v = validator code, c = cluster): v=2 skipped, c=1 block → late skip: voted skip on a slot that made the chain (counted in skippedNotarizedSlots) v=2 skipped, c=2 skipped → agreeing skip (correct, paid) v=1 notarized, c=2 skipped → notarized a block that did not make the chain (possible: reward certs aggregate the votes the next leaders saw, not the final outcome; rare) v=3 missed, c=1 block → no vote seen on a block that landed v=3 missed, c=2 skipped → no vote seen on a skipped slot A slot's reward certificates can carry both a notarize and a skip aggregate (some validators saw the block in time, some did not); the cluster code is the chain's outcome, not either certificate. ---------------------------------------------------------------- GET|POST /epochs/leader-schedule ---------------------------------------------------------------- The epoch's leader per slot, compactly: one index into `leaders` per 4-slot leader window. $ curl -s 'https://api-testnet.vx.tools/epochs/leader-schedule?epoch=1050' { "epoch": 1050, "firstSlot": 448076256, "slots": 432000, "slotsPerEntry": 4, // 1 if some window has two leaders "leaders": ["Node...", ...], // node identities "leaderIndexesBase64": "..." // little-endian u16 per entry } Request: { "epoch": u64 | null } // default: current epoch; 404 if not stored Decode: entry e = u16 at bytes 2e..2e+1; slot firstSlot + i has leader leaders[entry(floor(i / slotsPerEntry))]. Immutable once complete: cache per epoch. Stored by the Alpenglow ingestor from RPC when it reaches an epoch, so a whole epoch's leaders are known from its first slot (on mainnet, ahead of the block-ingestor, which trails the public ledger warehouse); older epochs fall back to the block-ingestor's per-slot leaders. An epoch neither covered has none. ---------------------------------------------------------------- GET|POST /epochs/leaderboard/income ---------------------------------------------------------------- Block-income leaderboard for one epoch. $ curl -s 'https://api.vx.tools/epochs/leaderboard/income?epoch=969' \ | jq '.records[0:2]' $ curl -sX POST https://api.vx.tools/epochs/leaderboard/income \ -H 'content-type: application/json' \ -d '{"epoch": 969}' | jq '.records[0:2]' { "epoch": 969, "records": [ { "nodeName": "(cluster stats)", "nodeAddress": "111...", "stake": 0, "totalSlots": 432000, "confirmedSlots": 430000, "skippedSlots": 2000, "totalIncome": BlockIncome, "medianIncome": BlockIncome, "minIncome": BlockIncome, "maxIncome": BlockIncome, "datacenter": null, ... "provider": null, "softwareBase": null, ... }, { "nodeName": "Acme", "nodeAddress": "Foo...", "stake": 123456789012, ... "provider": "Vultr", ... } ] } Request: { "epoch": u64 | null } Notes: first row is the synthetic "(cluster stats)" baseline (same filter rule as the voting leaderboard). ================================================================ FIELD GLOSSARY ================================================================ datacenter Format: "--", e.g. "20473-US-Atlanta", from the node's gossip address looked up in DB-IP Lite (see /validators). `null` when the node is not in gossip or the geo database is unavailable. Partial resolution yields the sentinel pieces `0` (no ASN), empty country, or `Unknown` city, e.g. "0--Unknown". continent, country DB-IP's English names for the gossip address ("Europe", "United States", "United Kingdom", "The Netherlands"). `null` when `datacenter` is null. provider Hosting brand when the validator runs on a managed-hosting provider in our curated allowlist: "Vultr", "OVH", "Hetzner", "Latitude.sh", "TeraSwitch", "Allnodes", "Cherry Servers", "AWS", "Hivelocity", "Leaseweb", "CDN77", and similar. The literal string "Self-hosted" otherwise — covers Tier-1 transit carriers (Cogent, Lumen, NTT, Arelion), consumer/business ISPs (Charter/Spectrum, ARTERIA, Vorboss), validator-operator-owned ASNs (RockawayX, Galaxy Digital, Twinstake, Kraken), AND the long-tail unclassified set. The specific carrier/operator brand is NOT surfaced via the API. `null` only when `datacenter` is null, ASN is 0, or the ASN is not in api-server/data/dc_providers.json at all. softwareBase Runtime family, from the client id the node publishes in gossip: "Agave" - Agave, AgaveBam, JitoLabs, HarmonicAgave, Rakurai, Raiku "Frankendancer" - Frankendancer, HarmonicFrankendancer "Firedancer" - Firedancer, FireBAM, HarmonicFiredancer null - not in gossip / unrecognised id with an unparseable version / synthetic ideal row An unrecognised client id falls back to the version's shape (0.x = Frankendancer, small major = Agave, large major = Firedancer). softwareFlavor Fork on top of the base: "BAM", "Jito", "Harmonic", "Rakurai", "Raiku". `null` for stock distributions (plain Agave, plain Firedancer); callers can render absence as "" or " vanilla". softwareVersion Version string the node publishes in gossip (e.g. "4.3.0", "4.3.0-rc.1", "0.1204.40300", "26.9.5"). perfectSlots / okSlots / slowSlots / skippedSlots `okSlots`, `slowSlots` are stored directly on the row. `perfectSlots = votedSlots - okSlots - slowSlots` (server-derived). `skippedSlots = idealVoter.votedSlots - validator.votedSlots` (server-derived from the synthetic ideal-voter row). Sentinel: `okSlots == 0 && slowSlots == 0 && votedSlots > 0` means the row predates the slot-classification backfill — treat as "no data" rather than "perfect performance". rank Competition rank by earnedCredits, 1-indexed among real validators, ties shared, next distinct record jumps past the tie group. The synthetic ideal row always sits at rank 0. Only the voting leaderboard carries it; the income leaderboard has no rank field. totalExcessLatency Skip-aware aggregate vote latency. Value `0` is ambiguous: it can mean "perfect performance" OR "not yet populated by the backfill". Combine with `lastUploadedBucket` to disambiguate. votesBase64 Per-slot vote history packed into a binary buffer, then base64- encoded. Truncated to (completedBuckets * BUCKET_SIZE) bytes; trailing all-zero buckets are not transmitted. ================================================================ GOTCHAS ================================================================ Ideal-voter / cluster-stats baseline row Voting and income leaderboards prepend a synthetic row representing cluster-wide optimal performance. Its nodeAddress is the all-zeroes pubkey (base58: "11111111111111111111111111111111"), nodeName is "(optimal record)" or "(cluster stats)" depending on the leaderboard. ALWAYS filter this out before ranking real validators — don't treat it as the winner. It also surfaces in /epochs/vote-stats if you happen to query the zero pubkey (don't). Cache TTLs on leaderboards Current-epoch leaderboard is refreshed every 10s by a background task; a foreground request stale-refetches if the entry exceeds 30s (only happens when the background task is wedged). Past-epoch leaderboards are cached lazily on first request with a 10-min TTL so backfill rewrites (vote-excess-latency, fill-epoch) surface quickly. Skipped slots are derived, not stored For both vote-stats and voting leaderboard, `skippedSlots` is computed as (ideal_voted - voted). If the ideal-voter row is missing from a given epoch (very rare, in-progress backfill), `skippedSlots` will be 0 — interpret as "unknown", not "no skips". "provider": "Self-hosted" is a category, not a brand See the field glossary. If you need the actual carrier name (Cogent, Spectrum, etc.), it's not exposed via the API by design — only managed-hosting brands are passed through. The source map is in api-server/data/dc_providers.json in the repo if you need it for ops. Latency buckets in /blocks/insights bucket 0 = "didn't vote" for this slot buckets 1..=30 = raw latency in slots bucket 31 = saturated (actual latency >= 31) Empty leaderboard responses If the block-ingestor is wedged, a current-epoch leaderboard request can return `records: []`. Past-epoch requests don't have this failure mode. The server emits a Slack alert when this happens; the response itself is otherwise normal. ASN 0 in datacenter The literal sentinel "0--Unknown" appears as the `datacenter` value when the gossip address is in neither DB-IP database (rare; private or very new ranges). The validator is still real — just unlocated. provider, continent and country are null in this case. A validator not in gossip at all has `datacenter: null` instead. Epoch boundaries Solana mainnet has 432,000 slots per epoch. The /epochs/current endpoint returns the current value but DOES change mid-request when an epoch flips. Endpoints that default `epoch` to the current value capture the value at request entry, so a flip mid-response is impossible. ================================================================ DATA SOURCES & REFRESH CADENCE ================================================================ Per-validator directory (identity <-> vote account, stake, delinquency): Source: Solana RPC getVoteAccounts Refresh: every 60 s (background task on the api-server) Software client and version, gossip address: Source: Solana RPC getClusterNodes (the node's own gossip contact info) Refresh: every 60 s Names, website, keybase, icon: Source: on-chain validator-info accounts (Config program), only those signed by the identity they describe Refresh: hourly Geolocation (datacenter, continent, country, provider ASN): Source: DB-IP Lite city + ASN databases (https://db-ip.com, CC BY 4.0 — credit "IP Geolocation by DB-IP"), looked up on the gossip address Refresh: monthly editions, checked daily; lookups follow the 60 s directory refresh Identity balances and vote-account state (/validators/status): Source: Solana RPC getMultipleAccounts Refresh: balances every 60 s, vote-account state every 5 min SFDP required versions and participants: Source: https://api.solana.org (Solana Foundation Delegation Program) Refresh: hourly SFDP matching (SfdpProgramModel) The Foundation sizes each delegation as a flat "residual" plus a "matching" component that partially matches the validator's non-Foundation stake, capped, with amounts that move as the pool rebalances. It publishes the rule in words only, so the api-server fits it to the live population at every scan: delegation = min(cap, baseline + matchRate × externalStake) served as { "baseline": u64, "matchRate": f64, "cap": u64, "fitValidators": usize, "fitResidualP50": u64 } (lamports). On 2026-10-02: baseline 33.3k SOL, matchRate 0.49, cap 83.4k SOL, half of 161 fitted validators within 262 SOL of the line. Per validator, `matching` = effective - baseline (the matched part, a model-derived split: the chain does not label which lamports are which) and `shortfall` = expectedDelegation - effective, where expectedDelegation is 0 when a measurable residual criterion fails (`criteria.eligible` false: not Approved, total stake over 1M SOL, commission over 5%, version below the current minimum, data centre over 15% or ASN over 25% of cluster stake) and shortfall is 0 while `inTransition` (the largest SFDP account activated this epoch or last, the Foundation's stated rebalance delay; the newest account is not the signal, since the program tops most validators up with a small extra account every epoch). A sizeable shortfall on an eligible, settled validator therefore means matching is withheld by a criterion this server cannot measure: the 5-epoch skip rate within 5 points of the cluster average (the matching criterion), Jito MEV commission <= 10%, multi-ASN company concentration, testnet baseline, metrics reporting, responsiveness. SFDP stake per validator: Source: on-chain stake accounts whose stake authority is the Foundation's delegation key (mpa4abUkjQoAvPzREkh5Mo75hZhPFQ2FSH6w7dWKuQ5), run through the runtime's warmup/cooldown arithmetic with the StakeHistory sysvar Refresh: every 5 min Testnet gossip (SFDP testnet-node check): Source: testnet RPC getClusterNodes Refresh: every 60 s Vote stats, voting leaderboard, income, vote history: Source: Solana RPC -> block-ingestor -> Google BigTable Refresh: per-slot ingestion in real time; aggregates computed per-epoch on the boundary Provider mapping (ASN -> brand/kind): Source: hand-curated allowlist in scripts/update-dc-providers.py, validated against RIPEstat + PeeringDB Refresh: manual (run `python3 scripts/update-dc-providers.py`, commit the regenerated api-server/data/dc_providers.json, restart the api-server) ================================================================ REPORTING ================================================================ Reach out on Discord — validators will know how.