A read-only HTTP cache for IQ Labs on-chain data. Fetches from the blockchain and serves it over HTTP with multi-tier caching. Anyone can run their own gateway.
One codebase, two chains. A single IQ_CHAIN env var selects the backend:
IQ_CHAIN=solana(default) — Solana (devnet / mainnet-beta / testnet) via solana-sdkIQ_CHAIN=evm— EVM chains (Sepolia / Monad / Monad Testnet) via ethereum-sdk
Cache, RPC queue, ETag/304, SSE, and the server shell are shared. The chain adapter, route set, OpenAPI spec, and home page are selected per process. See Architecture and Chains.
- No single point of failure - if one gateway goes down, spin up another
- Your own cache - faster for your users, your region
- Data is always recoverable - everything lives on-chain, any gateway can serve it
- No vendor lock-in - switch gateways anytime, same data
git clone https://github.com/IQCoreTeam/iq-gateway.git
cd iq-gateway
bun install
cp .env.example .envSolana (.env):
IQ_CHAIN=solana
SOLANA_CLUSTER=mainnet-beta
SOLANA_RPC_ENDPOINT=https://api.mainnet-beta.solana.com
PORT=3000
EVM (.env):
IQ_CHAIN=evm
IQETH_NETWORK=sepolia # sepolia | monad | monadTestnet
IQETH_RPC_ENDPOINT=https://rpc.sepolia.org
PORT=3000
Run:
bun run devThat's it. Your gateway is live at http://localhost:3000.
One process = one chain (fault isolation; matches the Solana cluster model). The
chain is fixed at boot by IQ_CHAIN; the network within it by SOLANA_CLUSTER
(Solana) or IQETH_NETWORK (EVM).
Solana (IQ_CHAIN=solana) |
EVM (IQ_CHAIN=evm) |
|
|---|---|---|
| tx identifier | signature (base58, 86–90) |
txHash (0x + 64 hex) |
| wallet / account | base58 pubkey | 0x 20-byte address |
| table key | table PDA — /table/{pda}/… |
dbRootId + tableName — /table/{dbRootId}/{tableName}/… |
| row tx field | __txSignature |
__txHash |
| name service | SNS (/sns/…, *.sol.site) |
ENS (/ens/…) |
| token gate | SPL balance / ATA | ERC-20 / native balance |
| site hosting | /site/… (manifest) |
— (not in v1) |
| fast reads | Helius (gTFA + batch) | Alchemy (batch + higher limits) |
EVM networks:
| Network | Chain ID | Contract |
|---|---|---|
| sepolia | 11155111 | 0x246A08D9fdD9b3990A88eD1f2DF1A87239839F07 |
| monad | 143 | 0x7ae06f87Cf93606DA2BD6A281afB28028cAE233D |
| monadTestnet | 10143 | 0x3379883538C068978e199472b5D127055c734867 |
Shared
| Variable | Required | Description |
|---|---|---|
IQ_CHAIN |
No | solana (default) or evm — selects the chain adapter + route set |
PORT |
No | Server port (default: 3000) |
BASE_PATH |
No | URL prefix if behind reverse proxy |
MAX_CACHE_SIZE |
No | Max disk cache before cleanup (default: 10GB) |
ADMIN_TOKEN |
No | Bearer token — enables /admin/* queue tuning when set |
Solana (IQ_CHAIN=solana)
| Variable | Required | Description |
|---|---|---|
SOLANA_CLUSTER |
Yes | devnet, mainnet-beta, or testnet |
SOLANA_RPC_ENDPOINT |
Yes | Solana RPC URL (must match cluster) |
HELIUS_API_KEY |
No | Helius API key for faster reads (paid plan enables gTFA + batch) |
HELIUS_API_KEYS |
No | Comma-separated Helius keys for 429 fallback (overrides HELIUS_API_KEY) |
BACKFILL_FROM_SLOT |
No | Start slot for historical backfill (requires paid Helius). Set to 398615411 for full IQ Labs history |
EVM (IQ_CHAIN=evm)
| Variable | Required | Description |
|---|---|---|
IQETH_NETWORK |
Yes | sepolia, monad, or monadTestnet |
IQETH_RPC_ENDPOINT |
Yes | EVM JSON-RPC URL (chain ID validated against the network at boot) |
ALCHEMY_API_KEY |
No | Enables batched reads + higher rate limits |
ENS_RPC_ENDPOINT |
No | Mainnet RPC for ENS resolution (default https://eth.llamarpc.com) |
KNOWN_DBROOTS_FILE |
No | Seed file of dbRootIds for /dbroots discovery (default ./config/known-dbroots.json) |
Paths below use Solana identifiers ({sig}, {pda}). On IQ_CHAIN=evm the
shapes change: {sig} → {txHash} (0x…), and {pda} → {dbRootId}/{tableName}.
Two routes are Solana-only (Site Hosting,
SNS); one is EVM-only (ENS). Everything
else exists on both chains. The live /openapi.json and /docs always reflect the
active chain.
| Endpoint | Description |
|---|---|
GET /meta/{sig}.json |
Metaplex-compatible JSON metadata |
GET /img/{sig}.png |
Raw image/file bytes |
GET /data/{sig} |
Raw asset data |
GET /view/{sig} |
Rendered HTML view of text inscriptions |
GET /render/{sig} |
PNG/SVG render of text inscriptions |
| Endpoint | Description |
|---|---|
GET /table/{pda}/rows |
Read table rows with pagination. Supports If-None-Match -> 304 Not Modified via weak ETag. Rows include __txSignature, __signer (fee payer), and __blockTime (chain-truth timestamp). Head-page refresh is gated by table meta lastTimestamp so unchanged tables avoid extra signature scans. |
GET /table/{pda}/index |
Full signature index for a table |
GET /table/{pda}/slice |
Read specific rows by signature |
GET /table/{pda}/meta |
Table metadata (name, columns, lastTimestamp, gate config) |
POST /table/{pda}/notify |
Notify about a new tx for instant cache injection. Also pushes to open SSE streams. |
GET /table/{pda}/subscribe |
Server-Sent Events stream. Emits event: hello on connect, event: row on each /notify, event: ping every 30s. Clients use new EventSource(...) instead of polling. |
GET /table/{feedPda}/thread/{threadPda} |
Resolved {op, replies, totalReplies} in one call. Server-side OP picker (prefers row with sub, tiebreak earliest time) removes the two-fetch + client-side OP-resolution pattern. |
GET /table/dbroot |
DB root info (tables, creators) |
GET /table/cache/stats |
Cache statistics |
| Endpoint | Description |
|---|---|
GET /user/{pubkey}/assets |
List assets uploaded by a wallet |
GET /user/{pubkey}/sessions |
List user sessions |
GET /user/{pubkey}/profile |
User profile data |
GET /user/{pubkey}/state |
Raw user state account |
GET /user/{pubkey}/connections |
User connections |
GET /user/{pubkey}/posts |
Signatures this wallet has authored. Opportunistic index — populated at decode time, so coverage grows as the gateway serves traffic. {pubkey, signatures, count, note}. |
| Endpoint | Description |
|---|---|
GET /gate/{tablePda}/check/{wallet} |
Server-side token-gate check. Returns {sol, gate, tokenBalance, meetsGate, minSol}. Replaces the client's getBalance + getAccount calls for gated-board UX. Cached 30 s. |
EVM only (
IQ_CHAIN=evm). Replaces the Solana SNS routes. Uses a dedicated mainnet RPC (ENS_RPC_ENDPOINT), cached 30 min.
| Endpoint | Description |
|---|---|
GET /ens/{name} |
Forward resolve an ENS name → {name, address}. If the segment is an address, reverse-resolves → {address, name}. |
GET /ens/{addr}/reverse |
Reverse resolve an address → primary ENS name {address, name}. |
| Endpoint | Description |
|---|---|
GET /openapi.json |
OpenAPI 3.0 schema for every endpoint (active chain) |
GET /docs |
Interactive Swagger UI with gateway-hosted assets |
Solana only. No EVM equivalent in v1 (no on-chain manifest convention established for the EVM SDK yet).
| Endpoint | Description |
|---|---|
GET /site/{manifestSig} |
Serve a website from Solana (index.html) |
GET /site/{manifestSig}/{path} |
Serve any file from an on-chain manifest |
GET /site/{manifestSig}/manifest |
Return the normalized manifest as JSON ({manifestSig, indexPath, files}). For clients that want to render sites themselves instead of consuming the served HTML. |
Supports both Iqoogle and gateway manifest formats. SPA fallback for unknown paths. Root-relative asset requests (e.g. /logo.webp) are resolved against the active manifest.
Deploy a site:
bun run scripts/deploy-site.ts ./my-site ./keypair.json| Endpoint | Description |
|---|---|
GET /dbroots |
Lists every DbRoot the iqlabs program owns, in one call. Uses getProgramAccounts with a memcmp filter on the Anchor DbRoot discriminator, so the response stays small (one row per dApp). Cached 30 min — DbRoots only mutate when a new dApp launches or a table is registered. Returns the raw DbRoot fields with no derivation: {dbroots: [{pda, id, idHex, creator, tableCreators, extCreators, tableSeeds, globalTableSeeds}], fetchedAt, count}. Each table-seed is {label, hex, tablePda} — label is the utf-8 view (or null when the hint is an already-hashed seed), hex is the raw hint bytes, and tablePda is the pre-derived Table PDA (the gateway runs the SDK derivation once per refresh so a client just string-compares an incoming pubkey to classify it). |
| Endpoint | Description |
|---|---|
GET /cache/info |
Entry count, total size, by-type breakdown |
GET /cache/entries |
Paginated disk-cache entry index. Supports type, indexed q (3-256 chars), limit, and opaque cursor. |
GET /cache/entries/{id} |
One disk-cache entry with a bounded decoded preview when possible. |
GET /cache/blob/{id} |
Raw cached bytes for a disk-cache entry. |
GET /cache/memory |
Process-local memory-cache counts, or paginated searchable keys/previews with cache=<name>&q=<text>&includeValues=true. Memory clears on restart; value-preview pages are capped lower than key-only pages. |
GET /cache/snapshot |
Streamed tar.gz of the full cache (cache.db + blob dirs). VACUUM-INTO consistent. Public read; lets a cold gateway warm up from a hot peer without re-fetching every entry from chain. See Cache Snapshot below. |
| Endpoint | Description |
|---|---|
GET /health |
Health check + cache stats |
GET /version |
Server version info |
GET / |
Terminal-styled homepage with live stats |
Three-tier cache with different TTLs:
| Layer | TTL | Purpose |
|---|---|---|
| Memory (LRU) | 60s head, 5min other | Fast reads |
| Disk (SQLite) | 5min rows, 24h immutable | Persistent across restarts |
| Chain (Solana / EVM) | Permanent | Source of truth |
The cache layer is chain-agnostic — the same SQLite store, LRU, and dedup serve
both chains. Run a separate CACHE_DIR per (chain × network) instance so base58
and 0x keys never collide — give each instance its own persistent volume.
Rows head pages use the table account's lastTimestamp as a cheap change
gate. If the timestamp is unchanged, the gateway can keep serving the cached
head page; if it changed, the background refresh catches up new signatures and
stamps the fresh timestamp once the indexed signature list overlaps the cache.
Individual rows are cached for 24 hours (on-chain data is immutable). Head page responses are cached for 60 seconds with throttled background refresh.
With a paid Helius plan (HELIUS_API_KEY), the gateway automatically uses:
- gTFA (
getTransactionsForAddress) — 100 full transactions per call, ~100x faster reads for session files - Batch JSON-RPC — multiple
getTransactioncalls in one POST for table row reads - Backfill — pre-cache all historical IQ Labs transactions on startup (set
BACKFILL_FROM_SLOT)
Without Helius, everything still works using standard Solana RPC — just slower for large files.
The gateway ships as a chain-agnostic container (see the repo Dockerfile). How you run it — bare VPS, docker compose, Kubernetes, Akash, anything — is up to your infra. The gateway only asks for the following contract:
| Requirement | Detail |
|---|---|
| Port | Listens on PORT (default 3000). |
| Env | IQ_CHAIN + the matching network/RPC vars (see Configuration). Inject however your platform does env (--env-file, secrets, etc.). |
| Persistent volume | Mount durable storage at CACHE_DIR (default /app/cache). Use one volume per (chain × network) instance so caches don't collide. Survives restarts; safe to wipe. |
| TLS / routing | Terminate TLS and route your domain to the container at your proxy/ingress layer. The gateway speaks plain HTTP. |
Minimal local run:
docker build -t iq-gateway .
docker run -d -p 3000:3000 -v iq-cache:/app/cache --env-file .env --restart unless-stopped iq-gatewayThat's the whole contract — port, env, a persistent CACHE_DIR, and a proxy in front. Everything else is your platform's concern, not the gateway's.
Public read-only snapshot of the gateway's disk cache so a cold peer can bootstrap from a hot one without re-fetching every entry from Solana. The snapshot streams tar -czf - directly — first byte arrives quickly even for large caches, and Cloudflare's 100s edge timeout doesn't bite.
# warm a cold gateway from a peer's hot cache
./scripts/bootstrap-cache-from-peer.sh https://gateway.iqlabs.dev ./cache
# then start (or restart) your gatewaycurl -sS https://peer-gateway/cache/snapshot | tar -xz -C ./cache| Endpoint | Description |
|---|---|
GET /cache/info |
entry count + total size + by-type breakdown |
GET /cache/entries |
paginated disk-cache entry index for external explorers; q is indexed and requires 3-256 chars |
GET /cache/entries/{id} |
one disk-cache entry with metadata + bounded preview |
GET /cache/blob/{id} |
raw cached bytes for an entry |
GET /cache/memory |
process-local memory-cache counts, searchable keys, and optional bounded previews |
GET /cache/snapshot |
streamed tar.gz of the full cache (cache.db + blob dirs). VACUUM-INTO consistent. public read. |
The gateway is read-only by design — no POST /cache/restore or /sync-from-peer. Operators write to their own cache directory directly (filesystem op on their own host); they don't write across the network.
The explorer endpoints are intentionally paginated/read-only so a separate site can browse cache contents without asking the gateway to dump a multi-GB cache as one JSON response. Responses use opaque entry ids and never expose the local cache filesystem path. Disk previews are byte-bounded; memory value previews are smaller and process-local because memory cache is only the current gateway process state.
The gateway is a read-only cache layer. It never writes to chain. All data is public and recoverable from chain, so multiple gateways serve the same data independently.
The only real divergence between chains lives in src/chain/. A ChainReader
interface (src/chain/types.ts) is the seam; two adapters
implement it:
src/chain/
types.ts # ChainReader interface (the shared intersection)
solana/ # web3.js + Helius + SNS → implements ChainReader
evm/ # ethers + Alchemy + ENS → implements ChainReader
index.ts # picks ONE adapter by IQ_CHAIN, re-exports its surface
src/routes/
*.ts # Solana route set
evm/ # EVM route set
src/cache/ # shared store/LRU/dedup; catalog-ingest.evm.ts for EVM row shape
src/server.ts # branches on IQ_CHAIN — mounts one route set, validates one network
Importing the inactive adapter is side-effect-free (no top-level RPC or env
throw); EVM defers its provider/network wiring into initEvm(), called from
initChain() only when IQ_CHAIN=evm.
Solana only. On
IQ_CHAIN=evmthese routes are not mounted — EVM uses ENS instead.
The gateway resolves Solana Name Service (SNS) domains to on-chain IQ manifests at request time. One URL record on a .sol domain powers three browser surfaces — no server-side coordination required.
On your .sol domain via sns.id, set:
Record.URL = https://gateway.iqlabs.dev/site/<your-sig>/<your-index-file>
Per the SNS web-resolution spec, the URL record is the canonical "this is my website" pointer. Brave's native .sol resolver and sol-domain.org check it first.
| URL | How it resolves |
|---|---|
gateway.iqlabs.dev/sns/<name> |
JSON {domain, owner, record} — the domain's owner wallet + raw SOL-record value (a wallet/PDA), for dispatcher clients to classify. ?fresh=1 skips the 24h cache. |
gateway.iqlabs.dev/sns/<name>/record |
Reads the URL record on-chain, 302s to /site/<sig>/<file> (site serving) |
<name>.sol (in Brave with SNS resolution enabled) |
Brave reads the URL record, navigates there |
<name>.sol.site/<file> |
Requires a CNAME alongside (see below) |
$ curl -L https://gateway.iqlabs.dev/sns/<your-name>/record
# → 302 → /site/<sig>/<your-index-file> → on-chain contentThe resolver accepts the domain bare (/sns/nubs/record) or with the .sol / .sol.site suffix.
For the prettier <name>.sol.site URL, also set a CNAME via the "Configure Sol.site" UI on sns.id:
Record.CNAME = sns.iqlabs.dev
sns.iqlabs.dev is a direct A record to the gateway origin (no Cloudflare proxy). Sol.site materialises the CNAME as DNS → traffic reaches the gateway → host middleware reads the URL record → manifest served.
The URL record can be:
- A full URL:
https://gateway.iqlabs.dev/site/<sig>/<file>(recommended — works for Brave AND our gateway) - A bare 86–90 char Solana tx signature (works for our gateway only)
The resolver also reads Record.TXT as a fallback.
Lookups are cached 5 minutes (memory + disk, both positive and negative). Concurrent cold-cache requests for the same domain are deduplicated so only one Solana RPC call fires.