Cascade

Services

The Inscribe Go services — indexer that mirrors Injective contract state to Postgres, API that serves joined views and proxies Cascade reads, plus the migrate runner.

Inscribe ships two Go binaries plus a migration runner, all in a single module under services/. The indexer is the source of truth for "the current state of all known markets"; the API is the read-only surface the frontend talks to. Both are independent processes; either can be restarted without losing work.

services/
├── cmd/
│   ├── api/                inscribe-api binary
│   ├── indexer/            inscribe-indexer binary
│   └── migrate/            inscribe-migrate binary (goose-based)
├── internal/
│   ├── api/                chi router + handlers
│   ├── cascade/            sn-control HTTP client
│   ├── db/                 pgx pool + query helpers
│   ├── indexer/            poll-and-mirror loop
│   ├── injective/          LCD client (contracts-by-code, smart queries)
│   └── types/              shared model (mirrors contract state)
├── migrations/             goose SQL migrations
└── docker-compose.yml      local Postgres on :5433

inscribe-indexer

A poll-and-mirror loop. Every tick (default 10s):

  1. Lists all inscribe-market-v2 instances via Injective LCD's wasm/contracts-by-code/{code_id} endpoint.
  2. For each instance, smart-queries GetConfig, GetMarket, ListEvidence, GetProposal, GetChallenge, GetCommittee, ListVotes, GetSettlement, and GetPool.
  3. Mirrors the result into Postgres: markets, evidence, bets, proposals, challenges, votes, settlements, market_pools.
  4. Eagerly resolves Cascade CIDs in a separate worker, writing bodies to cascade_cache with their content-type and filename.

The indexer is stateless across restarts — it always reads the chain state of record. There is no Kafka, no offsets, no compaction. The Postgres tables are a materialized view; if you drop them and restart the indexer, they refill from chain state alone within minutes.

Running

# Local Postgres on :5433
docker compose up -d
 
export DATABASE_URL="postgres://inscribe:inscribe@127.0.0.1:5433/inscribe?sslmode=disable"
export INJECTIVE_LCD="https://testnet.sentry.lcd.injective.network:443"
export INSCRIBE_MARKET_V2_CODE_ID=39449
 
go run ./cmd/migrate up
go run ./cmd/indexer

The indexer logs one line per poll tick with the contracts seen and the state transitions written. A 24-hour soak test on testnet completes ~8600 polls without manual intervention; the only error class observed in practice is transient LCD 5xx, which the polling loop retries on the next tick.

Why polling and not events

CometBFT websocket events from a remote LCD are unreliable under network partitions and require ordered backfill on reconnect. Polling is simpler, deterministic, and recovers from any disconnect by just running another tick. The cost is a ~10-second worst-case latency from chain confirmation to indexed state — acceptable for this product (the frontend refreshes after a 12-second delay to give the indexer one full poll tick).

inscribe-api

HTTP service over the indexer's Postgres tables. CORS-permissive. Read-only except for POST /cascade/upload.

Routes

GET    /healthz
GET    /markets[?state=<state>&limit=<n>]
GET    /markets/{addr}
GET    /markets/{addr}/audit
GET    /cascade/{cid}
POST   /cascade/upload

GET /markets

Lists known markets, optionally filtered by state. limit defaults to 50, capped at 500.

curl -s 'http://127.0.0.1:8080/markets?state=voting&limit=20' | jq
{
  "markets": [
    {
      "address": "inj1qjm…",
      "spec_cid": "15823",
      "state": "voting",
      "creator": "inj14sc…",
      "created_block": 125377282,
      "settlement_block": 125377382,
      "challenge_window": 20,
      "voting_window": 20,
      "committee_size": 3,
      "committee_quorum_bps": 6000,
      "proposer_bond": "1000000000000000000",
      "challenger_bond": "1000000000000000000",
      "pool_yes": "100000000000000000",
      "pool_no":  "500000000000000000"
    }
  ],
  "count": 1
}

GET /markets/{addr}

Full per-market detail joined across all tables: market config + state, evidence list, bets list, pool totals, proposal, challenge, votes, settlement. Empty collections serialise as [], not null, so JS callers can .length / .map without defensive checks.

curl -s 'http://127.0.0.1:8080/markets/inj1qjmvp…/' | jq '. | { state, evidence: .evidence | length, votes: .votes | length, settlement }'

GET /markets/{addr}/audit

The audit viewer endpoint. Returns the same MarketDetail as above, plus an artifacts map keyed by every CID referenced (spec, evidence, proposer + challenger + voter justifications, settlement record). For each CID:

{
  "content_type": "text/markdown; charset=utf-8",
  "filename": "justification-yes-1715800000.md",
  "size_bytes": 712,
  "body": "## Proposed verdict: YES\n\n..."
}

body is the parsed JSON for JSON content types, or a UTF-8 string for text. Bodies > 64 KB are truncated in the JSON response (the raw bytes remain available via GET /cascade/{cid}).

This is intentionally a single fat endpoint. A market with 20 evidence items and a full voter trail has ~30 CIDs to resolve; doing them all on the server in parallel and shipping one JSON blob beats the round-trip cost of 30 separate fetches from the browser.

GET /cascade/{cid}

Streams the raw bytes of a Cascade artifact. Resolves from cascade_cache first; on miss, pulls from sn-control and write-through caches.

Content-Type:        sniffed from the artifact
Content-Disposition: inline; filename="<original>"
Cache-Control:       public, max-age=31536000, immutable
X-Inscribe-Cid:      <cid>

The immutable cache header is correct: Cascade content is content-addressed, so once fetched the bytes are correct forever. Browsers and CDNs can cache without further negotiation.

POST /cascade/upload

Multipart upload that drives the full server-signed Cascade write (see Cascade from Injective — Pattern B). Two-step pipeline:

  1. Shell out to inj-cw-upload (cascadego repo binary). This builds a MsgRequestAction, packs it for ICA dispatch via the cw-ica-controller, broadcasts on injective-888, and returns the Lumera action_id.
  2. POST the file body to sn-control /sn/upload with the action_id so the Supernode stores the actual content.
curl -s -F "file=@evidence.txt" http://127.0.0.1:8080/cascade/upload | jq
{
  "action_id": "15823",
  "filename":  "evidence.txt",
  "size":      248
}

Timeout is 5 minutes (overrides the surrounding chi.Timeout(60s)) because the IBC packet round-trip can take 30–60s. The endpoint has no authentication today; production deployments should put a quota-capped bearer token in front of it. See Cascade from Injective for funding requirements.

Environment

VariableDefaultPurpose
DATABASE_URLrequiredpgx connection string
INJECTIVE_LCDrequiredLCD endpoint for the indexer's smart queries
INSCRIBE_MARKET_V2_CODE_IDrequiredCode ID the indexer scans for instances
SN_CONTROL_URLhttp://4.239.243.216:8444Cascade gateway for downloads
SN_CONTROL_TOKENrequired for downloads + uploadsBearer auth for sn-control
CASCADEGO_DIR/Users/kaleab/Documents/cascadegoWhere inj-cw-upload lives
INJ_CW_UPLOAD_BIN$CASCADEGO_DIR/inj-cw-uploadOverride for the binary path
API_PORT / API_BIND8080 / 127.0.0.1HTTP listener

Database schema (high-level)

markets(addr PK, spec_cid, state, creator, created_block, settlement_block,
        challenge_window, voting_window, committee_size, committee_quorum_bps,
        bond_denom, bet_denom, proposer_bond, challenger_bond,
        bond_vault, voter_registry)

market_pools(market_addr PK, pool_yes, pool_no)

evidence(id PK, market_addr, cid, submitter, role, block_height,
         UNIQUE(market_addr, cid, block_height))

bets(market_addr, bettor, side, amount, last_updated_block,
     PRIMARY KEY (market_addr, bettor, side))

proposals(market_addr PK, proposer, verdict, justification_cid, proposed_block)
challenges(market_addr PK, challenger, counter_verdict, justification_cid, challenged_block)
votes(market_addr, voter, verdict, justification_cid, voted_block,
      PRIMARY KEY (market_addr, voter))
settlements(market_addr PK, final_verdict, finalized_block,
            settlement_cid, winning_justification_cid)

cascade_cache(cid PK, content_type, filename, body BYTEA, fetched_at)

Migrations live in services/migrations/ and run via inscribe-migrate up. Goose handles ordering and idempotency.

Production layout

The Inscribe MVP runs both processes locally during development. In production:

  • API on Vercel Functions (Fluid Compute, Node.js LTS) only after the Go binary is wrapped in a Node-callable handler — the Go service runs as a standalone container otherwise.
  • Indexer as a long-running container (Vercel doesn't run continuous workers natively).
  • Postgres on Vercel Marketplace (Neon).
  • cascade_cache table size grows with read traffic; periodic VACUUM is sufficient because every row is immutable.

See BUILD.md Phase 6 in the repo for the planned production wiring.

Local quick-check

After running migrations and starting the indexer:

# Health
curl -s http://127.0.0.1:8080/healthz | jq
 
# List
curl -s http://127.0.0.1:8080/markets | jq '.markets[] | {address, state}'
 
# Detail
curl -s http://127.0.0.1:8080/markets/inj1qjmvp3dd0zmcwtuxwnzhpcv9qaflfgjca6ax40 | jq
 
# Audit — every CID inlined
curl -s http://127.0.0.1:8080/markets/inj1qjmvp3dd0zmcwtuxwnzhpcv9qaflfgjca6ax40/audit \
  | jq '.artifacts | keys'
 
# Raw artifact
curl -s http://127.0.0.1:8080/cascade/15823 | head -c 200

Next steps

Edit this page

On this page