Skip to content

Latest commit

 

History

History
1479 lines (1174 loc) · 64.9 KB

File metadata and controls

1479 lines (1174 loc) · 64.9 KB

Internal API (/api/*)

UI-facing REST. Stateless Fastify service (backend/src/api.ts). Reads from Postgres only.

The public CoinGecko-compliant endpoints (/cg/*) are documented separately in CoinGecko.md.

The human-readable version of this file is served at /api, generated by backend/scripts/gen-api-docs.mjs.

Conventions

  • Transport: JSON, UTF-8, application/json; charset=utf-8. Only GET is exposed.
  • Numbers: amounts in groths are returned as strings (NUMERIC(40, 0) doesn't fit in JS number). The frontend divides by 10^decimals to display. USD numbers are JSON numbers (USD never overflows). Timestamps are unix seconds (number); heights are integers.
  • Errors:
    { "error": { "code": "PAIR_NOT_FOUND", "message": "no pair 0-31-1" } }
    code is stable; message is human-readable and may change.
  • Booleans in query strings: 1, true, yes, on are true; 0, false, no, off and an empty value are false (case-insensitive). Anything else is a 400 rather than a guess — a bool param that silently ignored ?flag=0 would be write-only.
  • Auth: none.
  • CORS: open (Access-Control-Allow-Origin: *). The in-wallet origin works the same way.
  • Rate limit: per-IP via @fastify/rate-limit, RATE_LIMIT_PER_MIN (default 600/min). Set to 0 to disable. Excess responses are { "error": { "code": "RATE_LIMITED", "message": "…" } }.
  • Cache-Control: per-route, typically public, max-age=15 or 30. /api/health is no-store.
  • API base in production: https://beamterminal.0xmx.net/api. The frontend hard-codes this in frontend/src/app/containers/Screener/api/client.ts.

GET /api/health

Liveness. Returns 200 with the lag between now() and cursor.updated_at. 503 if the cursor row is missing.

{
  "status": "ok",
  "last_indexed_height": 3863512,
  "lag_seconds": 14
}

Cache-Control: no-store. The indexer rate-limit applies but /api/health is logged at debug level only (so it doesn't drown the request log if a probe hits it every second).

GET /api/stats

Header strip data: BEAM/USD, supply and market cap, DEX and bridge TVL, 24h volume, lifetime volume, all-time high and low, pair/trade counts.

Example below uses real figures from 2026-08-23, so the arithmetic checks out: 192,100,675 × 0.00764726 = 1,468,921.51.

{
  "beam_usd": 0.00764726,
  "circulating_supply": 192100675.0,
  "market_cap_usd": 1468921.51,
  "total_tvl_usd": 42157.68,
  "bridge_tvl_usd": 38210.55,
  "volume_24h_usd": 49.47,
  "total_volume_usd": 1337274.88,
  "ath_usd": 4.28,
  "ath_ts": 1546617600,
  "atl_usd": 0.00616752,
  "atl_ts": 1783082120,
  "total_pairs": 94,
  "total_trades": 60237,
  "last_indexed_height": 4004167,
  "block_ts": 1787438794
}
  • circulating_supply — whole BEAM, from the aid-0 asset row (assets.emission), which services/beamSupply.ts syncs from the explorer's /status?exp_am=1 Current Circulation (emission plus released treasury). null until that sync has run.

    Note this is not the figure CoinGecko publishes: it lists 201,543,500 against the explorer's 192,100,675. The chain is the authority here; do not "correct" ours to match theirs.

  • market_cap_usd — circulating_supply × beam_usd. null if either input is missing; a market cap computed from half its inputs is worse than no answer.

  • total_tvl_usd — sums reserve1_usd + reserve2_usd per active pool. For pools where only one side has a USD rate (via the USD valuation routing), the known side is doubled (AMMs hold equal value on both sides at equilibrium). Pools where neither side is priceable are skipped.

  • bridge_tvl_usd — escrowed collateral across the bridges, valued off the Beam-side asset exactly as /api/bridge/health does. Bridges that cannot be priced are absent from the total rather than counted as zero, so this is null until at least one is priceable. Only the bridge_escrow slice is read here — the rest of /bridge/health groups over bridge_messages and is far heavier.

  • volume_24h_usd — sums one priced side per pool over the 24h trade window.

  • total_volume_usd — point-in-time-valued lifetime volume, served from the precomputed dex_stats cache (refreshed by the indexer every 5 minutes). null until the first refresh after a fresh deploy.

  • ath_usd / ath_ts — highest BEAM/USD ever, and when. Not simply max(oracle_snapshots): this indexer starts 2022-08-13 and the highest price it has ever seen is ~$0.29, whereas the real high is $4.28 on 2019-01-04, the day after genesis. The served value is the greater of that known constant and the oracle maximum, so a genuine new high supersedes it without a code change.

  • atl_usd / atl_ts — lowest BEAM/USD ever. This one is just min(oracle_snapshots) and needs no known-value floor: the all-time low is $0.00616752 on 2026-07-03, inside the indexed window. The asymmetry with the high is only that the price has fallen over the years, putting the high before this indexer existed and the low after.

Both extremes are cached in dex_stats because the queries behind them scan a hypertable with no index on beam_usd, and this endpoint is on the hot path.

  • block_ts — timestamp of the latest oracle snapshot (proxy for last-tick wall-clock time).

Cache-Control: public, max-age=15.

GET /api/pairs

List of all pairs with 24h stats baked in.

Query params:

Name Type Default Notes
sort_by tvl_usd | volume_24h_usd | price_change_24h | trades_24h | aid2 tvl_usd
order asc | desc desc
limit int, 1..500 100
offset int 0
search string — Substring match on short_name or exact AID. Splits on / so BEAM/USDT narrows to both sides.
kind 0 | 1 | 2 — Filter to one volatility tier.
include_imposters bool false If true, includes pools where either side has is_imposter = TRUE.
group tier | pair tier pair collapses fee tiers into one combined row per (aid1, aid2).

Grouped mode (group=pair)

The screener lists each pair once instead of once per fee tier. Reserves, volume_24h_*, trades_24h/buys_24h/sells_24h and tvl_usd are summed across tiers; price_native/price_usd/price_change_24h/sparkline_7d and the identity fields (pair_id, kind, lp_token) come from the reference (deepest) tier — the pool with the largest reserve1, matching the USD-valuation convention. Each grouped row adds a tiers[] array (one entry per fee tier, deepest first) carrying { pool_id, kind, kind_label, lp_token, tvl_usd, volume_24h_usd, reserve1_human, reserve2_human, price_native }. Sorting and slicing happen app-side after the merge.

Response shape (one entry shown — see frontend/src/app/containers/Screener/api/types.ts for the canonical TS type):

{
  "pairs": [
    {
      "pair_id": 17,
      "aid1": 0, "aid2": 31,
      "symbol1": "BEAM", "symbol2": "USDT",
      "kind": 1, "kind_label": "Medium",
      "decimals1": 8, "decimals2": 8,

      "price_native": 29.34293421,         // aid2 per 1 aid1
      "price_usd":    0.99,                // USD per 1 aid2 unit
      "rate_2_1":     0.03408871,          // 1 / price_native

      "reserve1":       "12000000000000",  // groths
      "reserve2":       "352115210340000",
      "reserve1_human": 120000.0,          // divided by 10^decimals
      "reserve2_human": 3521152.1,
      "reserve1_usd":   4092.00,
      "reserve2_usd":   3521152.10,
      "tvl_usd":        7613152.10,

      "volume_24h_groth": "94000000000",
      "volume_24h_usd":   3206.84,

      "price_change_24h": 4.91,            // percent

      "buys_24h":  18,
      "sells_24h": 22,
      "trades_24h": 40,

      "is_imposter": false,
      "lp_token": 12345,                   // pools.aid_ctl
      "created_at_height": 1234567
    }
  ],
  "total": 95,
  "last_indexed_height": 3863512
}

total is the full match count for the given filters (search/kind/include_imposters), independent of limit/offset, when the sort runs in SQL. On the app-side sort path (see below) it is the size of the loaded super-set after grouping, so it never exceeds 500.

Sort routing

tvl_usd and volume_24h_usd are computed app-side from the USD-per-AID rates (which come from the multi-hop helper, not SQL). When sorted by either, the handler pulls a default-ordered window of up to 500 rows, sorts in JS, and slices to [offset, offset+limit]. total on that path counts the loaded super-set, so it never exceeds 500. Other sort keys go straight to SQL.

This matters because a tiny pool with huge raw-groth reserves but only ~$37 of USD value would otherwise rank above genuinely deep pools.

Cache-Control: public, max-age=15.

GET /api/pairs/{id}

Single pair, same fields as a row in /api/pairs.

id is one of:

  • A numeric LP-token aid (/api/pairs/12345) — a single tier.
  • A canonical tier tuple <aid1>_<aid2>_<kind> (/api/pairs/0_31_1; legacy - separators also accepted) — a single tier, stable across DB recreations and suitable for bookmarkable URLs.
  • A combined-pair tuple <aid1>_<aid2> (/api/pairs/0_31) — returns the grouped row across all fee tiers (summed stats + tiers[], reference-tier price), the same shape as a group=pair list entry.

404 (PAIR_NOT_FOUND) when no matching pool exists.

The row also carries ctl_supply (total LP-token supply, groths) and snapshot_height (the height the reserves/ctl_supply are from) — these power the liquidity-position analyzer.

Cache-Control: public, max-age=15.

GET /api/lp-position/deposit

Resolves a single Liquidity Add deposit so the frontend can analyse a liquidity position (share, fees, P&L, impermanent loss — all computed client-side from this plus /api/pairs/{id}). Exactly one query param:

  • height=<n> — looked up in lp_events (Postgres only).
  • kernel=<64 hex> — the one endpoint that touches the explorer: a single /block?kernel= call maps the kernel to its block height, then the same lp_events lookup runs. We don't index kernel ids, hence the hop.

Returns a deposit object (lp_token, aid1/aid2, symbols, decimals, kind + fee_pct, amount1/amount2/amount_ctl in groths, height, ts, confirmed). When a height holds several deposits, returns { "candidates": [ … ] } for the UI to disambiguate. 404 (DEPOSIT_NOT_FOUND / KERNEL_NOT_FOUND) when nothing matches.

Cache-Control: public, max-age=30.

GET /api/lp-position/events

Multi-operation lookup powering the liquidity-position analyzer: resolves every add/remove liquidity op across a list of references, so a position with several deposits and partial withdrawals can be valued. One query param:

  • refs=<…> — up to 50 block heights and/or 64-hex kernel ids, comma/space/ newline separated. Kernel ids are resolved to heights via the explorer (the only explorer touch); refs that don't resolve — or that exceed the 50 cap — come back in unresolved.

Returns { "pools": [ … ], "unresolved": [ … ] }, with ops grouped by pool (lp_token). Each pool carries its asset/fee metadata, present-time per-unit BEAM/USD prices (current_beam_per_aid1, current_usd_per_aid1, …), and an events[] list; each event has kind (Deposit/Withdraw), amount1/2/ctl, height, ts, confirmed, and the historical BEAM/USD price of each asset at that op's height (beam_per_aid1, usd_per_aid2, … — null when the pair has no BEAM route). All P&L / share / partial-withdrawal accounting is computed client-side from this plus /api/pairs/{id}.

Cache-Control: public, max-age=30.

GET /api/pairs/{id}/ohlcv

Chart candles. Accepts any id form from /api/pairs/{id}. For a combined pair (<aid1>_<aid2>) the price OHLC is taken from the reference (deepest) tier while volume/trade_count are summed across all tiers over the reference tier's bucket window. (Buckets where only a thinner tier traded — and the reference tier did not — are not drawn.) Single-tier ids return that pool's series unchanged.

Query params:

Name Type Default Notes
interval 1m | 5m | 15m | 1h | 4h | 1d 1h
limit int, 1..2000 500
to int (unix seconds) now Returns limit candles strictly older than to. Cursor for scroll-back.
denom native | usd native If usd, OHLC values are converted using BEAM/USD valid at each candle's bucket. Only meaningful when one side of the pair is BEAM (aid 0).

Response:

{
  "candles": [
    {
      "time": 1747400940,
      "open": 29.341, "high": 29.401, "low": 29.305, "close": 29.380,
      "volume": "9400000000",      // groths of aid1, string
      "trade_count": 7
    }
  ],
  "interval": "1h",
  "denom": "native",
  "more": { "to": 1747314540 }    // cursor for the next older page, or null when exhausted
}

USD conversion details:

  • For a pool with aid1 = 0 = BEAM: native price is aid2-per-BEAM, so USD-per-aid2 = beamUsd / native_price. The route inverts high/low after conversion since the inversion swaps extremes.
  • For aid2 = 0: USD-per-aid1 = native_price * beamUsd directly.
  • For pools with no BEAM side: denom=usd silently falls back to native — the route can't construct a USD reference without an oracle path.
  • The handler binary-searches per candle into the oracle history fetched for the candle window; for candles older than oracle history (e.g. backfilled trades pre-dating our deployment), it falls back to the most recent oracle snapshot.

Cache-Control: public, max-age=30.

GET /api/pairs/{id}/trades

Recent trades or LP events for one pair. A combined-pair id (<aid1>_<aid2>) interleaves rows across every fee tier; a single-tier id returns just that pool's rows.

Query params:

Name Type Default Notes
kind Trade | lp Trade lp returns Deposit + Withdraw events.
limit int, 1..200 50
before int (unix seconds) now Cursor mode — "load more" pagination.
before_id int > 0 — Pairs with before: id of the last row of the previous page (trade_id for kind=Trade, event_id for kind=lp). When both are given the page resumes exactly after that row ((block_ts, id) < (before, before_id)), so rows that share a block timestamp are never skipped. Ignored without before.
offset int ≥ 0 — Numbered pagination. When present, overrides before.
count bool false Also return total (pool's full row count) for "Showing X to Y of N".
include_unconfirmed bool true UI shows unconfirmed with a marker; CG endpoints always exclude.

In offset mode the response echoes offset and limit, and (when count=true) total; before and before_id are null.

Trade response:

{
  "trades": [
    {
      "trade_id": 81729,
      "timestamp": 1747400940,
      "height": 3863500,
      "aid_in": 0, "aid_out": 31,
      "amount_in":  "1000000000",
      "amount_out": "29342934",
      "side": "buy",                   // computed: aid_in == aid1 → buy
      "price_native": 29.34293421,
      "price_usd": 0.99,               // USD per aid2 unit via the base's USD rate; null without a USD path
      "value_usd": 9.93,               // volume_aid1 (in whole units) × USD rate of aid1
      "confirmed": true,
      "confirmations": 80              // truncated to 80 once confirmed
    }
  ],
  "before": 1747400940,                // oldest timestamp in the page (next page cursor)
  "before_id": 48213                   // id of the oldest row in the page — pass back with `before`
}

LP-event response (kind=lp):

{
  "trades": [
    {
      "event_id": 1027,
      "timestamp": 1747400940,
      "height": 3863500,
      "kind": "Deposit",
      "amount1": "10000000000",
      "amount2": "352115210000",
      "amount_ctl": "1095445115",
      "liquidity_pct": 0.18,           // signed share of the pool this event added/removed (Withdraw < 0)
      "confirmed": true
    }
  ],
  "before": 1747400940,
  "before_id": 9137
}

Cache-Control: public, max-age=15.

GET /api/trades

DEX-wide trade tape, newest first — the same rows /api/pairs/{id}/trades serves, but across every pool instead of one. Saves a consumer from fanning out over ~100 pairs to see what the DEX is doing.

Query params:

Name Type Default Notes
limit int, 1..200 50
before int (unix seconds) now Cursor — "load older".
before_id int > 0 — Pairs with before: trade_id of the last row seen. Together they form a keyset cursor ((block_ts, trade_id) < (before, before_id)) that does not skip same-block trades. Ignored without before.
include_unconfirmed bool true
include_imposters bool false If true, includes pools where either side has is_imposter = TRUE.
kind 0 | 1 | 2 — Filter to one volatility tier.
aid int ≥ 0 — Only trades in pools that have this asset on either side.

There is no offset / count mode here. With no pool filter the head of the feed moves as the indexer ticks, so a numbered offset would silently skip or repeat rows between pages; cursor paging on before is the only stable option. Destroyed pools are always excluded.

{
  "trades": [
    {
      "trade_id": 81729,
      "pool_id": 17, "pair_id": 17,
      "aid1": 0, "aid2": 31,
      "symbol1": "BEAM", "symbol2": "USDT",
      "kind": 1, "kind_label": "Medium",
      "timestamp": 1747400940,
      "height": 3863500,
      "aid_in": 0, "aid_out": 31,
      "amount_in":  "1000000000",
      "amount_out": "29342934",
      "side": "buy",
      "price_native": 29.342934,
      "price_usd": 0.00026,
      "value_usd": 0.0764,
      "confirmed": true,
      "confirmations": 80
    }
  ],
  "before": 1747399118,
  "before_id": 48213,
  "limit": 50
}

before in the response is the oldest returned trade's timestamp — feed it back as the before param to page further. null when the page came back empty. before_id is that trade's trade_id; pass both back together. Paging on before alone still works but can drop the remaining trades of the block the previous page ended in.

As on the per-pair route, price_usd and value_usd are priced off the shared USD table: pools that aren't BEAM-quoted carry USD figures as long as their base asset is reachable through some BEAM-quoted pool. Both stay null when the base has no USD path.

Cache-Control: public, max-age=15.

GET /api/pairs/{id}/liquidity

Pooled-amount time series for one pool, decomposed by the source of the reserve changes. Drives the trade page's Pool History chart (two series: pooled aid1 + pooled aid2). A combined-pair id (<aid1>_<aid2>) sums the series across every fee tier per bucket (so it matches the grouped tvl_usd / pooled totals); a single-tier id returns that pool's series.

Query params:

Name Type Default Notes
source total | lp | trades total total = actual pooled reserves; lp = cumulative net deposits; trades = cumulative reserve change from swaps. total ≈ lp + trades.
interval 1h | 1d 1d Bucket width.
from int (unix seconds) — Trim returned buckets (cumulative series stay correct at the left edge).
to int (unix seconds) —

Response (amount* are groths of aid1/aid2; divide by 10^decimalsN):

{
  "series": [
    { "ts": 1700000000, "amount1": "545527910000000", "amount2": "82055439000000" }
  ],
  "decimals1": 8,
  "decimals2": 8
}

Cache-Control: public, max-age=30.

GET /api/assets

Catalog of every asset known to the backend. Wholesale (no pagination) — there are ~200 assets on mainnet today; small enough to send in one shot.

{
  "assets": [
    {
      "aid": 0,
      "name": "Beam", "short_name": "BEAM", "unit_name": "BEAM",
      "description": "Native BEAM asset",
      "decimals": 8,
      "is_imposter": false, "imposter_reason": null,
      "emission":  "26279999976873600",        // for aid 0: BEAM current circulation
      "minted_at_height": null,                // on-chain registration height
      "minted_at_ts": null,                    // epoch seconds for minted_at_height
      "minter_cid": null,
      "max_supply": "26280000000000000",       // for aid 0: BEAM total circulation
      "pool_count": 47
    }
  ]
}

Cache-Control: public, max-age=30.

GET /api/asset/{aid}

Single asset metadata + every active pool it participates in.

{
  "aid": 31,
  "name": "Tether USD",
  "short_name": "USDT",
  "unit_name": "USDT",
  "description": "Wrapped USDT bridged from Ethereum",
  "decimals": 8,
  "is_imposter": false,
  "emission": "1000000000000000",
  "minted_at_height": 1234567,         // on-chain registration height
  "minter_cid": "295fe749…d868",       // null if not minter-issued
  "max_supply": "5000000000000000",    // null if uncapped
  "pools": [
    { "pair_id": 17, "aid1": 0, "aid2": 31, "kind": 1, "tvl_usd": 7613.10, "amount": "31946584614" }
  ]
}

The pools.tvl_usd field is best-effort: it requires BEAM to be one of the sides (so the USD reference is reachable in one hop). Otherwise null. pools.amount is this asset's reserve in the pool — groths as a decimal string, null until the pool has a state snapshot.

400 (BAD_REQUEST) if aid isn't a non-negative integer; 404 (ASSET_NOT_FOUND) if no row exists.

Cache-Control: public, max-age=30.

GET /api/asset/{aid}/history

Mint / burn / create / destroy events for an asset. Pass-through over the explorer's /asset?id=<aid>, lightly parsed and cached.

Query params:

Name Type Default Notes
limit int, 1..500 100

Response:

{
  "aid": 31,
  "history": [
    {
      "height": 3862500,
      "event": "Mint",
      "amount":       "500000000",
      "total_amount": "1000500000000",
      "extra": ""
    }
  ],
  "cached": false
}
  • 400 if aid == 0 (BEAM) — there's no /asset?id=0 endpoint on the explorer; the asset detail page reads BEAM supply from /api/asset/0 instead.
  • In-process LRU cache, 5-minute TTL. Cache key includes limit so different page sizes don't collide.

Cache-Control: public, max-age=300.

GET /api/asset/{aid}/distribution

How much of an asset each contract currently has locked, plus the unlocked remainder. Live state, no history.

No query params.

Response:

{
  "aid": 7,
  "entries": [
    {
      "cid": "b8944fd3f6a62697a89b2a55acd1cb2e3893dadece99569706efa1da847dd440",
      "kind": "Nephrite v1",
      "amount": "14658636293475"
    }
  ],
  "unlocked": "173224572110680",
  "total": "187883208404155"
}
  • entries — one row per contract holding a non-zero balance of the asset, sorted by amount descending for aid == 0 (explorer order otherwise). kind is the parser's human-readable shader name ("DEX v0", "Nephrite v1", …), or the shader hash hex when the parser doesn't recognise the contract. Amounts are groths as decimal strings.
  • unlocked — supply not held by any contract; total — unlocked plus the sum of entries.
  • aid > 0 is a pass-through over the explorer's /asset?id=<aid> "Asset distribution" table.
  • aid == 0 (BEAM) is synthesized instead — the explorer rejects /asset?id=0 (BEAM has no asset-registry row), so the backend collects the aid-0 entry of every contract's Locked Funds table from /contracts and anchors unlocked to the circulating supply it tracks (emission on /api/asset/0). If the supply figure is momentarily unavailable, unlocked degrades to "0" and total covers only the locked sum.

Cache-Control: public, max-age=30.

GET /api/network

Canonical snapshot of current network health — hashrate, difficulty, average block time, and the chain tip — plus the last 60 blocks. Everything derives from a single read of block_metrics, and this is the single source of truth for the network_hashrate reported by /api/mining/pools and shown on the Health page.

No query params.

{
  "hashrate": 78528.17,
  "difficulty": 5141867.25,
  "avg_block_time": 65.58,
  "tip_height": 3935205,
  "recent": [
    {
      "height": 3935146,
      "ts": 1783283812,
      "difficulty": 5001484.5,
      "kernels": 2
    }
  ]
}
  • hashrate — network-wide hashrate in Sol/s (BeamHash III), the time-weighted average Σ difficulty / Δt across the window; null until at least two blocks are indexed.
  • difficulty — difficulty of the latest block; null if block_metrics is empty.
  • avg_block_time — mean seconds between blocks, Δt / (N − 1) across the window; null until at least two blocks are indexed.
  • tip_height — height of the current chain tip; null if block_metrics is empty.
  • recent — the last 60 blocks (fewer if the chain is shorter), ordered oldest → newest. Each entry:
    • height — block height.
    • ts — block timestamp in unix seconds (note: /api/mining/blocks reports ts as an ISO 8601 string; this endpoint uses epoch seconds).
    • difficulty — that block's difficulty.
    • kernels — number of kernels in the block.

The window is the last 60 blocks (≈ 1h at the 60s block target), matching the /api/charts/hashrate convention.

Cache-Control: public, max-age=30.

GET /api/mining/pools

Current snapshot of every tracked mining pool plus the live network hashrate.

No query params.

{
  "network_hashrate": 1234567.89,
  "block_height": 3863512,
  "blocks_24h_total": 1438,
  "pools": [
    {
      "id": "herominers",
      "name": "HeroMiners",
      "website": "https://beam.herominers.com",
      "payout_scheme": "PPLNS",
      "hashrate": 987654.32,
      "miners": 120,
      "workers": 145,
      "blocks_24h": 38,
      "last_block_height": 3863400,
      "last_block_ts": "2025-05-16T10:22:00.000Z",
      "fee": 1.0,
      "min_payout": 1.0,
      "updated_at": "2025-05-16T10:30:00.000Z",
      "hashrate_series": [950000, 970000, 987654],
      "blocks_past_hour": 2,
      "blocks_past_24h": 41
    }
  ]
}
  • network_hashrate — network-wide hashrate in Sol/s, derived as Σ difficulty / Δt across the last 60 blocks; null if block_metrics is empty.
  • block_height — height of the current network tip; null if block_metrics is empty.
  • blocks_24h_total — total network blocks in the past 24h (the distribution donut's denominator; the Unknown slice is this minus the attributed sum). 0 if block_metrics is empty.
  • Per-pool fields:
    • hashrate — pool hashrate in Sol/s as self-reported by the pool's API; null if the pool is unreachable.
    • miners, workers, blocks_24h, last_block_height, last_block_ts, fee, min_payout, updated_at — all null when the pool is offline or has never been polled successfully.
    • fee — pool fee in percent (e.g. 1.0 = 1%).
    • min_payout — minimum payout in BEAM.
    • hashrate_series — last ≤ 30 non-null hashrate snapshots (Sol/s), ordered oldest → newest. Empty array if no data yet.
    • blocks_past_hour — network blocks in the past hour attributed to this pool. 0 if none.
    • blocks_past_24h — network blocks in the past 24h attributed to this pool. 0 if none.

Pool stats are scraped from each pool's own API on every new block; the freshness of each entry reflects when the poll last succeeded.

Cache-Control: public, max-age=60.

GET /api/mining/blocks

Recent blocks with best-effort pool attribution, most-recent first.

Query params:

Name Type Default Notes
limit int, 1..200 50
offset int ≥ 0 0 Numbered pagination.
{
  "blocks": [
    {
      "height": 3863512,
      "ts": "2025-05-16T10:30:00.000Z",
      "mined_by": "HeroMiners"
    }
  ]
}
  • ts — ISO 8601 timestamp of the block (block_metrics.block_ts).
  • mined_by — pool display name resolved from the pool's own self-reported found-blocks feed; null = unattributed. BEAM blocks carry no on-chain pool tag, so attribution is best-effort only.

Cache-Control: public, max-age=30.

GET /api/charts/{series}

Historical chart data for a named series. All series share the same response shape:

{
  "series": [
    { "ts": 1700000000, "value": 1234567.89 }
  ]
}

ts is unix seconds (integer). value is a JSON number. Points are ascending by ts. Null values are omitted. Each series is served from an in-process server-side cache (pre-warmed at boot, refreshed every 30 minutes), so the response is fast even for series that require full-history Postgres aggregates.

Responses support ETag / If-None-Match conditional requests; a 304 is returned when the cached series has not changed.

Query params

All optional. With none, the endpoint returns the full daily history — the original shape, unchanged.

Name Type Default Notes
res 1m | 1h | 1d | 1M 1d Resolution. 1m and 1M apply only in range mode (below); in default mode any unrecognized value — including 1m and 1M — serves 1d. 1M buckets by whole calendar month and is only offered by series whose ladder includes it (currently the bridge-* series).
from unix seconds — Start of a zoom window. Must be sent together with to. Clamped to >= 0.
to unix seconds — End of a zoom window. Must be greater than from after clamping. Clamped to <= now + one bucket of res.

Default mode — no from/to. Returns the full-history series at daily (res=1d) or hourly (res=1h) resolution. Hourly exists for every series except beam-vol, dex-vol, blackhole, pools-created, pools-closed, and the bridge-* series; requesting res=1h on those silently serves 1d. Both resolutions come from the in-process cache described above, so Cache-Control is the per-series max-age in the table below. The response is the flat shape shown above (single-series) for every series in default mode, blackhole excepted (see its row below).

Range ("zoom") mode — from and to both present. Returns only the points inside [from, to), served from a separate tile-quantized, bounded cache, at res=1m|1h|1d|1M (default 1d). Supported for every series except beam-vol, dex-vol, blackhole, pools-created, and pools-closed, which are daily-only and return an empty series here. Bad bounds (non-numeric, or to ≤ from once clamped) yield 400 {"error":"bad from/to"}. Cache-Control: public, max-age=86400 once the whole window has settled (to older than the ~80-minute / 80-block confirmation horizon), otherwise max-age=60.

Window width cap. The window is served in tiles of 256 buckets. A window wider than 2 000 buckets plus two edge tiles (10 tiles: ~1.8 days at 1m, ~107 days at 1h) is served at the next coarser resolution on the series' ladder instead of the one requested, repeatedly until it fits; the coarsest rung (1d) is served at any width, bounded only by the from/to clamps above. Whatever is served is reported back in the body's res field, so a client that needs the finer resolution should narrow the window rather than assume the request was honored. A window that would still exceed 128 tiles at the coarsest rung yields 400 {"error":"range too wide for resolution"}.

In range mode the body carries an explicit kind discriminant instead of always being the flat shape — the shape is a fixed property of the series name, never of the query params, so a client never has to sniff the body to know which one it got — and a res field naming the resolution actually served (the requested one, or a coarser one per the cap above / the series' ladder):

{ "kind": "single", "res": "1h", "series": [ { "ts": 1700000000, "value": 1234567.89 } ] }
{
  "kind": "multi",
  "res": "1d",
  "series": [
    { "key": "beam2eth", "label": "Beam → Ethereum", "points": [ { "ts": 1700000000, "value": 12 } ] }
  ]
}

kind: "multi" is used by the three bridge-*-by-* series (split per direction / bridge / asset); every other series is kind: "single". Default mode never carries kind — it always returns the flat shape from the top of this section, except blackhole, whose default-mode body is documented in its row below.

Available series (the full set is defined in backend/src/api/routes/charts.ts):

Series name Unit max-age Source
hashrate Sol/s 600 s Postgres: Σ difficulty / Δt per day from block_metrics
difficulty raw difficulty 600 s Postgres: daily average from block_metrics
block-time seconds 600 s Postgres: daily average block interval from block_metrics
tvl USD 1800 s Postgres: end-of-day pooled reserves × BEAM/USD per day
price BEAM/USD 600 s Postgres: daily closing price from oracle_snapshots
market-cap USD 600 s Postgres + services/beamEmission.ts: daily closing price × circulating supply at that day's block height
coinbase count/day 600 s Postgres: blocks per day (one coinbase output per block)
assets cumulative count 600 s Postgres: cumulative registered confidential assets
dex-volume USD 1800 s Postgres: daily DEX volume in USD
beam-vol percent (annualized) 1800 s Postgres: 30-day rolling realized volatility of BEAM/USD
dex-vol percent (annualized) 1800 s Postgres: TVL-weighted per-pool 30-day rolling realized volatility
pools-created cumulative count 1800 s Postgres: cumulative pools created over time, from pools.created_at_height
pools-closed cumulative count 1800 s Postgres: cumulative pools destroyed over time, from pools.destroyed_at_height
transactions-daily count/day 600 s Explorer /hdrs
transactions-total cumulative count 600 s Explorer /hdrs
txos-total cumulative count 600 s Explorer /hdrs
utxos-total cumulative count 600 s Explorer /hdrs
size-total bytes 600 s Explorer /hdrs
archive-total bytes 600 s Explorer /hdrs
shielded-ins-daily count/day 600 s Explorer /hdrs
shielded-ins-total cumulative count 600 s Explorer /hdrs
shielded-outs-daily count/day 600 s Explorer /hdrs
shielded-outs-total cumulative count 600 s Explorer /hdrs
contracts-total cumulative count 600 s Explorer /hdrs
fees-daily groth/day 600 s Explorer /hdrs
fees-total cumulative groth 600 s Explorer /hdrs
contract-calls-daily count/day 600 s Explorer /hdrs
contract-calls-total cumulative count 600 s Explorer /hdrs
blackhole — 1800 s Multi-series: series is not a flat SeriesPoint[] but an array of per-asset cumulative-lock series (one line per asset locked in the BlackHole contract).
bridge-transfers count/day 300 s bridge_messages: transfer count per bucket, all bridges and directions combined
bridge-transfers-by-direction count/day 300 s bridge_messages: transfer count per bucket, split by direction (kind: "multi" in range mode)
bridge-transfers-by-bridge count/day 300 s bridge_messages: transfer count per bucket, split by bridge (kind: "multi" in range mode)
bridge-transfers-total cumulative count 300 s bridge_messages: running total of transfers
bridge-fees USD/day 300 s bridge_messages: relayer fees per bucket, priced at the bucket's cross-rate, all bridges and directions combined
bridge-fees-total cumulative USD 300 s bridge_messages: running total of relayer fees
bridge-tvl USD 300 s Reconstructed bridge collateral value locked, priced at each bucket's cross-rate
bridge-tvl-by-asset native units 300 s Reconstructed bridge collateral locked, split per asset (kind: "multi" in range mode); values are already scaled to display magnitude, not raw on-chain amounts

In default mode, Cache-Control is the per-series max-age listed above; range mode is scoped under Query params above.

GET /api/bans/actions

Full BANS registry action history (oldest→newest), from contract_call_events.

{
  "actions": [
    { "height": 1896200, "block_ts": "2021-05-01T12:00:00.000Z", "method": "Register", "name": "syntaxjak", "args": { "Periods": 3, "name": "syntaxjak" } }
  ],
  "meta": { "total": 999, "first_height": 1896200, "last_height": 3922383 }
}

Cache-Control: public, max-age=60. Returns empty actions when BANS_CID is unset.

GET /api/bridge/health

Liveness and peg backing for the six Beam↔EVM Pipe bridges — four b-asset bridges and BEAM/WBEAM on both Ethereum mainnet and Arbitrum One.

{
  "bridges": [
    {
      "bridge": "busdt", "label": "bUSDT", "chain_id": 1, "aid": 37,
      "eth_pipe": "0x7c3fe09e86b0d8661d261a49bfa385536b7077f9",
      "eth_token": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
      "asset_symbol": "bUSDT",
      "outgoing": { "pending": 99, "relayed": 0, "failed": 0, "unknown": 0, "unsettleable": 0, "skipped": 0, "total": 99 },
      "incoming": { "not_delivered": 2, "unclaimed": 2, "complete": 136, "unknown": 0, "total": 140 },
      "oldest_open_ts": "2022-06-14T09:12:00.000Z",
      "last_message_ts": "2026-08-08T13:56:26.000Z",
      "unclaimed_amount": 41.5,
      "escrow": { "locked": 3701.4022, "decimals": 6, "observed_at": "2026-08-08T23:40:00.000Z" },
      "minted": 3688.28,
      "over_collateral": 0,
      "collateral_ratio": 0.9964,
      "settlement_source": "etherscan"
    }
  ],
  "tvl_usd": 1234567.89,
  "tvl_priced": 6,
  "settlement_available": true
}

tvl_usd is the USD value of all locked collateral, priced off each bridge's Beam-side asset (the wrapped asset tracks its collateral 1:1). Bridges with no BEAM-quoted pool are excluded rather than counted as zero; tvl_priced says how many contributed.

Status vocabularies differ by direction, because the underlying contract states do:

  • beam2eth — pending (no settling Ethereum tx seen), relayed, failed, unsettleable, skipped, unknown.
  • eth2beam — not_delivered (the relayer never pushed it to Beam), unclaimed (delivered; the recipient hasn't signed ReceiveFunds), complete, unknown.

unclaimed is not an error: only the recipient can claim, so a message can sit there indefinitely. Don't count it as a bridge failure.

unsettleable and skipped are derived, not stored. Both refine pending, which otherwise reads as "on its way" for messages that will never move — the relayer gives up after three attempts rather than retrying indefinitely, so nothing is coming back for them:

  • unsettleable — the amount underflowed, making it larger than everything the bridge holds. There is nothing to release against it. Provable from the row.
  • skipped — a later message on the same bridge has already settled, so the relayer moved past this one. This catches stalls we cannot diagnose from our side (a fee under the relayer's minimum, for instance) without needing to know the reason.

Both are terminal, so neither counts toward oldest_open_ts, and both accept ?status= like any stored value.

over_collateral counts open beam2eth messages whose amount exceeds the collateral the bridge holds. Such a message cannot settle — there is nothing to release against it — so a non-zero count means either wrapped arithmetic or a deliberate attempt. It is worth surfacing because the Beam-side relay applies no amount check of its own: unlike the Ethereum-side relay, which rejects an overflowing amount + relayerFee outright, it forwards whatever the Pipe recorded provided the fee clears its minimum. null means the escrow snapshot is missing and nothing could be compared; 0 means checked and clear.

collateral_ratio is minted supply ÷ locked collateral. ≤1.0 means every wrapped unit is backed; small deviations are in-flight messages and relayer fees, and a ratio above 1 means more has been issued than is held.

Custody direction differs per bridge. The b-asset bridges lock collateral on Ethereum and mint on Beam, so escrow.locked is the Ethereum Pipe's balance and minted is the Beam asset's emission. BEAM/WBEAM is the reverse: native BEAM is locked in the Beam Pipe and WBEAM is minted on Ethereum, so escrow.locked is the Beam-side locked amount and minted is the WBEAM ERC20 total supply.

settlement_available is false when no ETHERSCAN_API_KEY is configured. In that state beam2eth messages stay pending and no failure can be observed — "failed": 0 then means "unverifiable", not "none". Cache-Control: public, max-age=30.

GET /api/bridge/lookup

Point lookup for a single transfer — "I sent this, where is it?". Takes either side's identifier via q: an Ethereum/Arbitrum transaction hash, or a Beam kernel ID.

{
  "query": "0x9a51…", "kind": "evm_tx", "resolved_height": null,
  "matches": [
    {
      "bridge": "beam-wbeam", "label": "BEAM / WBEAM", "direction": "eth2beam",
      "msg_id": 219, "status": "unclaimed", "amount": 605.47244962,
      "role": "origin",
      "explanation": "Delivered to Beam and waiting for you to claim it. …"
    }
  ]
}

kind is evm_tx, beam_kernel, beam_height, or unrecognised. A bare number is treated as a Beam block height — the only reference an outgoing message has, since the Pipe records no kernel ID per message, so the height shown in the transfers table can be pasted straight in. Both identifiers are 32 bytes so the input alone can't distinguish them: the EVM side is tried first, then the kernel is resolved to a Beam height (a height can carry several messages, so all are returned).

A Beam height — supplied directly or resolved from a kernel — matches any of the four ways a block can belong to a transfer: an outgoing message's src_height or src_call_height, or an incoming one's delivered_height or claimed_height. role says which end matched: origin for the side the transfer started on, settlement for the side it landed on.

explanation is plain-language guidance for the current state — notably, a pending Beam→Ethereum transfer or a not_delivered message usually means the relayer is waiting for Ethereum gas to come down, not that anything is lost. Cache-Control: public, max-age=10 — short, because a pending transfer's state is exactly what the caller is watching.

GET /api/bridge/messages

Individual bridge transfers, newest first.

Query params: bridge, direction (beam2eth|eth2beam), status, sort, dir, limit (default 50, max 500), offset.

sort is one of age (default), amount, fee, msg_id, bridge, status, direction; dir is desc (default) or asc. Unknown values fall back to the defaults rather than erroring. Note amount sorts on the raw stored value, which is denominated in each side's own units — meaningful within one bridge, less so across bridges with different decimals.

{
  "messages": [
    {
      "bridge": "beam-wbeam", "direction": "beam2eth", "msg_id": 496,
      "status": "pending", "amount": 15000.0, "relayer_fee": 95.42684516,
      "receiver": "24c32736a24f2ccc95a3249b17ea5c23222df71c",
      "src_height": 3981297, "src_call_height": 3981298, "src_block": null,
      "src_ts": "2026-08-07T01:03:30.000Z", "src_tx": null,
      "settle_tx": null, "settle_block": null, "settle_ts": null,
      "delivered_height": null, "delivered_ts": null,
      "claimed_height": null, "claimed_ts": null,
      "malformed": null
    }
  ],
  "total": 1283, "limit": 50, "offset": 0
}

src_height is the chain tip the Pipe contract recorded when the transaction was built; src_call_height is the block that actually contains the call, resolved against the contract's call history. Use src_call_height for explorer links — looking up src_height shows no contract activity, because the call landed a block later.

delivered_height / claimed_height are the Beam blocks in which an incoming (eth2beam) transfer was relayed to Beam and then claimed by its recipient, with delivered_ts / claimed_ts the matching block times. They are always null for beam2eth, and also null on the BEAM/WBEAM Pipes: those are upgradable2-wrapped, so the explorer reports their calls as Passthrough with the arguments stripped and there is no message id to key on.

amount and relayer_fee are scaled to the side the message was observed on: Beam-side decimals for beam2eth, Ethereum-side for eth2beam. These differ — bUSDT is 8 decimals on Beam against USDT's 6 on Ethereum. receiver is a 20-byte Ethereum address for beam2eth and a 33-byte Beam pubkey for eth2beam, hex, no 0x.

malformed names the reason a message's figures are not a real transfer, and is null for ordinary ones. Two shapes occur, and neither should be rendered as a number — both read as an enormous genuine transfer:

  • "overflow" — amount + relayerFee wrapped past uint256 in the Ethereum Pipe, which computes that sum with unchecked arithmetic (solidity ^0.7.2). The wrap drops the total the sender actually pays to almost nothing while the emitted amount stays enormous, so these are attempts on the bridge, not transfers; the relayer tests the same predicate and refuses them (bDAI 43–48, bUSDT 128/136, bWBTC 22). The stored figures are reported unchanged — there is no meaningful number to show.
  • "underflow" — a Beam-side amount that wrapped. Beam amounts are uint64 and the fee is subtracted from the transferred amount without a check, so a fee larger than the amount stores the wrapped difference. Here amount is reported unwrapped, as a negative number: how far the fee overshot. Nothing of value crossed.

The message is still listed either way — "the bridge accepted this" is exactly what the endpoint exists to show. Cache-Control: public, max-age=30.

GET /api/search

Global omnibox. One query string fans out across Postgres (assets, pools, dapps, publishers), two static catalogs (charts, app pages) and — only when the query's shape warrants it — the explorer.

Exactly one param: q (1–128 chars). 400 (BAD_QUERY) otherwise.

Explorer lookups are shape-gated and each bounded to 700 ms: an all-digits query tries a block height, 64 hex chars tries both a kernel and a contract id, and a bare name tries the BANS registry. A query matching none of those shapes never touches the explorer at all, which is what keeps the common case fast.

Contracts also resolve by name, from a static catalog of the CIDs in config — DEX, Oracle, DAO Vault, DAO Vote, DApp Store, BANS, Asset Minter, Black Hole. The explorer already answers CID → name (searching 729fe098… returns "DEX v0"); this is the way in from the other side, so q=dex returns the DEX contract alongside the DEX page. Matched synchronously with no I/O, in the same contract group as a CID hit, so the response shape is unchanged. Contracts whose CID is not configured are omitted. Deliberately not every contract the explorer has seen: those names come from a third-party parser and would need the imposter handling assets get.

{
  "query": "beam",
  "groups": [
    {
      "type": "asset",
      "label": "Assets",
      "items": [
        { "type": "asset", "id": "0", "title": "Beam (BEAM)", "subtitle": "AID 0",
          "href": "/asset/0", "score": 90, "flags": [], "color": null, "logoUrl": null }
      ]
    }
  ],
  "sources": { "db": "ok", "explorer": "ok" }
}

Item fields beyond type/id/title/subtitle/href/score/flags are per-type extras the UI uses for rendering (assets carry color and logoUrl, for instance); treat them as optional.

Groups come back in a fixed display order — pages, assets, pools, dapps, publishers, blocks, kernels, contracts, BANS domains, charts, IPFS — with empty ones omitted and items sorted by descending score.

sources tells the UI why a group may be missing rather than making it guess: db is ok | error, explorer is ok | error | timeout | skipped. A partial result is still a 200 — one slow explorer never fails the whole search.

Cache-Control: public, max-age=15.

GET /api/dao/overview

Landing summary for the DAO explorer: treasury value and asset count, all-time revenue, and the current governance epoch, plus a short merged activity feed.

{
  "treasury": { "value_usd": 9408.74, "assets": 38 },
  "revenue": { "all_time_usd": 9408.68 },
  "governance": { "current_epoch": 107, "active_proposals": 0, "turnout_pct": null },
  "recent": [
    { "kind": "flow", "height": 4004442, "ts": "2026-08-23T03:30:06.000Z",
      "method": "DEX", "funds": { "47": "+297030" } }
  ]
}

turnout_pct is null when the current epoch has no closed vote to measure. Cache-Control: public, max-age=60.

GET /api/dao/treasury

DaoVault holdings, the flows that produced them, and the USD value series.

{
  "total_usd": 9408.74,
  "holdings": [
    { "aid": 47, "symbol": "Nph", "amount": "470947472099", "value_usd": 5223.38, "pct": 55.52 }
  ],
  "flows": [
    { "height": 4004442, "ts": "2026-08-23T03:30:06.000Z", "method": "DEX",
      "funds": { "47": "+297030" } }
  ],
  "value_series": [ { "day": "2022-08-16", "usd": 276.67 } ]
}

funds is keyed by AID with signed groth strings (+ in, - out) — one flow can move several assets at once. amount is groths; pct is the holding's share of total_usd. Assets with no USD path contribute null value and are excluded from the total rather than counted as zero.

Cache-Control: public, max-age=60.

GET /api/dao/treasury/asset/{aid}

Per-asset drill-down: lifetime in/out totals plus the individual flows.

Name Type Default Notes
limit int, 1..500 100 Most recent flows first.
{
  "aid": 47,
  "deposits_groth": "470947472099",
  "withdrawals_groth": "0",
  "rows": [
    { "height": 4003503, "ts": "2026-08-22T11:51:40.000Z", "method": "DEX", "amount": "+297030" }
  ]
}

400 (BAD_REQUEST) for a non-integer or negative aid. Cache-Control: public, max-age=60.

GET /api/dao/revenue

Protocol revenue accrued to the DAO, sliced four ways.

{
  "total_usd": 9408.68,
  "series":    [ { "day": "2022-08-16", "by_asset": { "47": 12.4 } } ],
  "by_tier":   [ { "tier": 0, "usd": 0.57 } ],
  "by_source": [ { "source": "Nephrite", "usd": 4759.59, "pct": 50.59 } ],
  "top_pools": [ { "pool_id": 30, "pair": "BEAM/Nph", "tier": 2, "usd": 1181.11 } ]
}

Revenue is valued at the point in time it accrued, not at today's price — the same convention as total_volume_usd on /api/stats.

Cache-Control: public, max-age=300 (the heaviest DAO query; it aggregates the full history).

GET /api/dao/governance

DaoVote state: current epoch, staked BeamX, and every proposal with its tally.

{
  "current_epoch": 107,
  "total_staked": "363984601011879",
  "kpis": { "active_proposals": 0, "turnout_pct": null,
            "voting_power": "363984601011879", "voters": 0 },
  "voting_power_series": [ { "day": "2022-07-04", "staked": 14111.1 } ],
  "epochs": [],
  "proposals": [
    { "id": 4, "epoch": 93, "title": "…", "status": "closed", "outcome": "passed",
      "variant_count": 2, "quorum_pct": 50, "yes_needed": "181992300505940",
      "turnout_pct": 58.3, "voted_groth": "265555615982351", "tallies": [] }
  ]
}

Groth strings are BeamX stake weight, not vote counts — voting power is stake. status is open | closed; outcome is null while a proposal is open.

Cache-Control: public, max-age=60.

GET /api/dao/governance/proposals/{id}

One proposal in full, including its description (markdown) and a paginated voter list.

Name Type Default Notes
offset int ≥ 0 0
limit int, 1..200 25
{
  "proposal": { "id": 4, "epoch": 93, "title": "…", "description": "…",
                "forum_link": "…", "status": "closed", "outcome": "passed",
                "variant_count": 2, "quorum_pct": 50, "yes_needed": "…",
                "turnout_pct": 58.3, "voted_groth": "…", "tallies": [] },
  "votes": {
    "total": 4,
    "rows": [
      { "voter": "2f79…5301", "variant": 1, "label": "Yes",
        "weight": "57756791178674", "height": 3920348 }
    ]
  }
}

The proposal object carries the same fields as a /api/dao/governance entry plus description (markdown) and forum_link. votes.total is the full voter count, unaffected by offset/limit, so a client can page without a second request. weight is the voter's BeamX stake in groths.

400 (BAD_REQUEST) for a non-integer id, 404 (NOT_FOUND) when no such proposal exists. Cache-Control: public, max-age=60.

GET /api/oracle

Current state of the Oracle2 price feed (ORACLE_CID), projected by services/oracle2.ts from the oracle2_app.wasm app shader.

{
  "cid": "4f160f01dcc6751e61d793279b803328d5332125fe8492e93ee8f3bfe9abe13b",
  "kind": "Oracle2 v0",
  "height": 4024924,
  "refreshed_at": "2026-09-06T09:41:12.004Z",
  "h_validity": 220,
  "min_providers": 3,
  "median": null,
  "median_h_end": 0,
  "median_valid": false,
  "valid_providers": 2,
  "quorum": false,
  "providers": [
    { "index": 2, "pk": "69a12cff…212a7601", "value": "0.010839069",
      "h_updated": 4024911, "age": 13, "stale": false }
  ]
}

height is the chain tip the snapshot was taken at; age and stale are measured against it, an entry being stale once age > h_validity. median is the median stored on-chain, which the contract recomputes only when a provider writes to it — null (with median_h_end: 0) means no median has been written since the last settings change, and median_valid says whether the stored one still covers height. quorum is valid_providers >= min_providers. Values are decimal strings in USD.

503 (UNAVAILABLE) until the indexer has written its first snapshot — which needs WALLET_API_URL, as the shader runs inside the wallet daemon. Cache-Control: public, max-age=30.

GET /api/dapps

The DApp Store registry, projected from on-chain calls by services/dappStore.ts.

Name Type Default Notes
include_deleted bool (1/true) false Include dapps with deleted_at set.

Returns { "dapps": [ … ] }, newest-updated first, capped at 500. Each entry: id, publisher { pubkey, name }, name, description, category, icon, ipfs_id, api_version, min_api_version, version (+ version_parts), first_seen_height / first_seen_at, last_updated_height / last_updated_at, deleted_at.

The height/timestamp fields are nullable: a dapp published under a multi-dapp publisher has no unambiguous on-chain attribution, so we return null rather than guessing. version is composed from version_parts when the projector didn't record a version string.

Cache-Control: public, max-age=60.

GET /api/dapps/{id}

One dapp plus its full version history.

{
  "dapp": { "id": "…", "name": "BeamTerminal", "…": "…" },
  "versions": [
    { "version": "1.0.0.0", "ipfs_hash": "…", "height": 3980112,
      "block_ts": "2026-07-01T10:00:00.000Z", "action": 0 }
  ]
}

versions is oldest-first. action is the raw registry opcode from the contract call that produced the row. 404 (DAPP_NOT_FOUND) when unknown. Cache-Control: public, max-age=60.

GET /api/dapps/publishers

Known publishers, most-prolific first, capped at 500.

Each entry: pubkey, name, short_title, about_me, website, social { twitter, linkedin, instagram, telegram, discord }, the same nullable first_seen_* / last_updated_* pairs as /api/dapps, and dapps_count (non-deleted dapps only).

Cache-Control: public, max-age=60.

GET /api/dapps/calls

Raw DApp Store contract calls, newest first — the projection's source of truth. While the projector is still maturing, read this to see what the registry actually contains before trusting a projected row.

Name Type Default Notes
limit int, 1..1000 200
action int — Filter to one registry opcode.

Returns { "calls": [ { "kernel_id", "call_index", "height", "block_ts", "action", "args", "confirmed" } ] }. args is the explorer's decoded argument object, passed through unchanged — its shape follows the parser, not us.

Cache-Control: public, max-age=30.

GET /api/dapp/{cid}

Streams a .dapp bundle out of BEAM's private mainnet IPFS swarm (wallet-api → asio-ipfs → bitswap). This is what the site's Download button points at.

Name Type Default Notes
filename string <cid>.dapp Sanitized, then sent as Content-Disposition: attachment.

Open gateway by design — any CID streams, with no allowlist check against the dapps table.

Errors: 400 (BAD_CID) when the path segment doesn't look like a CID; 503 (IPFS_UNAVAILABLE) when wallet-api has no IPFS configured; 503 (IPFS_CONTENT_UNAVAILABLE) when no swarm peer serves the CID. The last one is deliberately 503 rather than 504 — Cloudflare rewrites origin 502/504 bodies, and the Download button needs the JSON error to explain itself. The IPFS fetch is bounded at 60 s, under Cloudflare's 100 s origin-response limit.

GET /ipfs/{cid}

General-purpose read-only IPFS gateway over the same transport. Served at the site root without the /api prefix, matching the path shape of every other IPFS gateway, so a client can swap ipfs.io / dweb.link for this host unchanged.

Name Type Default Notes
download bool (1/true) false Force Content-Disposition: attachment.
filename string <cid> Sanitized before use.

Content-Type is sniffed from the payload. Types that could script in our origin (text/html, image/svg+xml, XML, application/wasm) are always coerced to application/octet-stream and forced to attachment regardless of download, so the browser saves them instead of rendering them. Responses also carry X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Content-Security-Policy: default-src 'none'; sandbox; frame-ancestors 'none'.

Same error codes as /api/dapp/{cid}. CIDs are immutable, so Cache-Control: public, max-age=31536000, immutable.

GET /api/asset-swaps

Wallet-gossiped asset-to-asset swap offers, mirrored from the wallet-api by services/assetSwapOffers.ts. These are peer-to-peer offers, not AMM pools — the explorer cannot serve them.

Name Type Default Notes
include closed | all — Also return offers we've marked gone. Changes sort to last_seen_at DESC.
send int (AID) — Filter by the asset being sent.
receive int (AID) — Filter by the asset being received.

Default is open offers only (gone_at IS NULL AND expire_time > now()), soonest to expire first, capped at 500.

{
  "offers": [
    {
      "id": "…", "is_my": false,
      "send":    { "asset_id": 0,  "amount": "1000000000", "currency_name": "BEAM" },
      "receive": { "asset_id": 31, "amount": "29342934",   "currency_name": "USDT" },
      "create_time": "2026-08-23T03:00:00.000Z",
      "expire_time": "2026-08-24T03:00:00.000Z",
      "first_seen_at": "2026-08-23T03:00:30.000Z",
      "last_seen_at":  "2026-08-23T04:00:30.000Z",
      "gone_at": null
    }
  ]
}

first_seen_at / last_seen_at / gone_at are our observation window, not chain facts — gossip has no on-chain record, so an offer is "gone" once it stops being re-advertised. Cache-Control: public, max-age=15.

GET /api/atomic-swaps

Cross-chain (BEAM ↔ BTC/LTC/ETH/…) atomic-swap offers, mirrored the same way.

Name Type Default Notes
include closed | all — Also return offers marked gone.
currency string — Counter-currency symbol, case-insensitive (BTC, USDT, …).
side beam | counter — Which side of the swap the offer is on.

Default is open offers only, newest first, capped at 500. Each entry: tx_id, is_beam_side, status + status_string, beam_amount, swap_amount, swap_currency + swap_currency_name, time_created, min_height, height_expired, and the same first_seen_at / last_seen_at / gone_at observation window as /api/asset-swaps.

Cache-Control: public, max-age=15.

GET /api/atomic-swaps/totals

Latest aggregate atomic-swap totals snapshot.

{
  "latest": {
    "ts": "2026-08-23T04:00:00.000Z",
    "height": 4005100,
    "total_swaps_count": 1284,
    "offered": { "BEAM": "…", "BTC": "…", "LTC": "…", "QTUM": "…", "DOGE": "…",
                 "DASH": "…", "ETH": "…", "DAI": "…", "USDT": "…", "WBTC": "…" }
  }
}

latest is null before the first snapshot lands. Amounts are strings in each currency's smallest unit. Cache-Control: public, max-age=30.

GET /api/atomic-swaps/totals/history

The same totals as a time series, for charting.

Name Type Default Notes
since ISO 8601 30 days ago
bucket 15m | 1h | 1d 1h Down-sampling interval.

Returns { "bucket", "since", "points": [ … ] } where each point has the same shape as latest above, oldest first. Buckets aggregate with MAX — these are monotonic cumulative totals, so the bucket's high is its end state.

Cache-Control: public, max-age=60.

Social-card images

Three routes render images rather than JSON, for link unfurls and embeds. They are part of the public surface but aren't meant to be consumed as data. All three live at the site root, without the /api prefix, so the URLs stay short and hotlinkable.

Route Type Notes
GET /og/site.svg image/svg+xml Site-wide Open Graph card: headline stats.
GET /og/pair/{id}.svg image/svg+xml Per-pair card. id takes the same forms as /api/pairs/{id}.
GET /pair/{id}/chart.png image/png Rendered price chart for one pair.

/pair/{id}/chart.png accepts days (int 1..3650 or all, default 1), w (200..2000, default 720) and h (150..1200, default 360). Candle granularity follows days — 5m up to a day, widening to 1d beyond a year — so the image stays readable at every range. An unknown pair returns a 404 whose body is still a PNG carrying a "Pair not found" message, so an embedding client renders something rather than a broken image.

Cache-Control: public, max-age=300 on the SVG cards.

Quote endpoint — intentionally not present

The swap panel asks the AMM shader for quotes directly (pool_trade with bPredictOnly=1) via the user's wallet. We don't mirror that on the server because:

  1. The shader has to run anyway when the wallet executes the trade.
  2. A server quote would drift from on-chain reality between request and broadcast.
  3. Local AMM math is trivial (constant product) for instant UI feedback before the wallet's authoritative quote arrives.

The swap panel's local estimate uses dy = r2·dx / (r1+dx) · (1 - fee) with the latest reserve1 / reserve2 from /api/pairs/{id}. See frontend.md §Wallet integration.

USD valuation

Backed by backend/src/api/repos/usd.ts:loadUsdTable. For each request that needs USD figures, the route loads:

  • beam_usd — latest oracle_snapshots.beam_usd.
  • A perAid: Map<aid, usdPerWholeUnit> built by routing each non-BEAM asset through its deepest BEAM-quoted pool (highest reserve1 BEAM groths). Assets without a BEAM-quoted path get no rate.

The same table is used everywhere — /api/stats, /api/pairs, /api/pairs/{id}, /cg/tickers — so USD numbers match across surfaces by construction.

Freshness model

The frontend polls. There's no WebSocket in v1.

  • /api/stats — every 60 s.
  • /api/pairs?... — re-fetched on mount or when sort/filter changes. Not auto-polled.
  • /api/pairs/{id} — every 30 s on the detail page.
  • /api/pairs/{id}/ohlcv — once on mount, plus on interval/denom change and on scroll-back (uses the more.to cursor).
  • /api/pairs/{id}/trades — initial load + 30 s top-of-feed refresh that splices new trades onto the head.

The useFetcher / usePolling hooks live in frontend/src/app/containers/Screener/hooks.ts. They keep last-known data on screen during refreshes so the UI doesn't flicker between loaded and "Loading…" every interval.

In-wallet, the same hooks run unchanged. A future v1.x can opt into per-block refresh via BeamDappConnector.subscribe('ev_system_state', …) and drop the 30 s timers; the API doesn't need to change.

Response-time targets

Measured against a 4-core / 8 GB VPS with ~50 k trades indexed:

Endpoint p50 p95
/api/health <5 ms 20 ms
/api/stats 20 ms 80 ms
/api/pairs 40 ms 180 ms
/api/pairs/{id} 20 ms 100 ms
/api/pairs/{id}/ohlcv 25 ms 120 ms
/api/pairs/{id}/trades 15 ms 80 ms
/api/asset/{aid}/history (cold) 200 ms 1 s (cached: <10 ms)

/api/asset/{aid}/history is the outlier because it round-trips to the explorer; the 5-minute LRU keeps the hit rate high in practice.

Versioning

The endpoints documented here are mounted at /api/*. When we break shape, we'll add /api/v2/* alongside (the version segment isn't parsed today; it's a future affordance, not a current contract).