openapi: 3.1.0
info:
  title: Launchpad API (Robinhood Chain testnet)
  version: 0.1.0
  description: |
    Public, keyless, non-custodial API for a fair-launch bonding-curve token
    launchpad on Robinhood Chain (chain id 46630, ETH gas). Read endpoints
    return market data; POST /v1/tx/* endpoints return UNSIGNED transactions
    plus a `signingUrl` the human can open to review and sign in one tap —
    the API never holds keys or funds. Amounts are decimal strings in wei
    (ETH) or 18-decimal base units (tokens). TESTNET ONLY.
servers:
  - url: http://localhost:4010
paths:
  /v1/config:
    get:
      summary: Chain id, launchpad/fee-vault addresses, supply constants
      responses: { "200": { description: OK } }
  /v1/tokens:
    get:
      summary: List/search tokens (the launch feed)
      parameters:
        - { name: sort, in: query, schema: { type: string, enum: [new, volume, mcap, trades] } }
        - { name: q, in: query, description: "name, ticker, contract address, or creator address", schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 200 } }
      responses: { "200": { description: "tokens[] with priceWei, marketCapWei, curveProgress (0..1 to graduation), change1h/24h, spark[]" } }
  /v1/tokens/{address}:
    get:
      summary: One token's full state
      parameters: [{ name: address, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: OK }, "404": { description: unknown token } }
  /v1/tokens/{address}/trades:
    get:
      summary: Recent trades (newest first)
      parameters: [{ name: address, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: OK } }
  /v1/tokens/{address}/candles:
    get:
      summary: OHLCV candles (prices in wei per token)
      parameters:
        - { name: address, in: path, required: true, schema: { type: string } }
        - { name: interval, in: query, description: seconds, schema: { type: integer, default: 300 } }
      responses: { "200": { description: OK } }
  /v1/tokens/{address}/holders:
    get:
      summary: Top holders with supply percentages
      parameters: [{ name: address, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: OK } }
  /v1/trades/recent:
    get:
      summary: Latest trades across all tokens (the live feed)
      responses: { "200": { description: OK } }
  /v1/chain/tokens:
    get:
      summary: Full ranked RHC token universe (chain-wide discovery, actives first, long tail included)
      description: |
        THE LIST CONTRACT the discovery board renders. Served from the cached,
        SQLite-persisted universe crawl (never a per-request Blockscout hit).
        Response:
          { source, fetchedTop, universeComplete, fetchedAt, activeCount,
            total,            # ranked rows matching the filters, BEFORE paging
            count, offset,    # this page
            categories: {memecoin, stablecoin, equity, wrapped},
            tokens: [{ address, name, symbol, decimals, priceUsd, marketCapUsd,
                       volume24hUsd, holders (int|null — null = unknown),
                       iconUrl, reputation, category, active, copycat?, dupes?
                       (+change1h/change24h/pairAddress/liquidityUsd/txns24h
                        when CHAIN_STORE_ENABLED=1) }] }
        universeComplete=false ⇒ Blockscout degraded mid-crawl; serving the
        best-known snapshot (monitor via /v1/health.universe).
      parameters:
        - { name: category, in: query, schema: { type: string, enum: [memecoin, stablecoin, equity, wrapped, all], default: memecoin } }
        - { name: limit, in: query, schema: { type: integer, default: 500, maximum: 5000 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: all, in: query, description: include scam-flagged/uncategorized rows, schema: { type: boolean } }
      responses: { "200": { description: "see endpoint description" } }
  /v1/chain/search:
    get:
      summary: Full-universe token search (any of the ~66k RHC tokens, long tail included)
      description: |
        Proxies Blockscout server-side search, then enriches hits from the
        crawled universe + a bounded round of token-detail fetches so rows carry
        REAL holders/mcap/volume (Blockscout's search payload has none — a raw
        proxy showed "0 holders" on every row). holders is null when genuinely
        unknown; clients must hide it, never print 0. Also the backend of the
        slop-scope cross-search fallback (no dead-end searches).
      parameters: [{ name: q, in: query, required: true, schema: { type: string } }]
      responses: { "200": { description: "{ query, tokens: ChainToken[] } ranked by mcap, then holders" } }
  /v1/chain/tokens/{address}/candles:
    get:
      summary: First-party OHLCV for any pooled RHC token (persistent Swap-event candles; falls back to a live pool scan for untracked tokens and promotes them)
      description: |
        THE CHART CONTRACT for external tokens (/v1/chain/candles?token= is the
        same data as a query param). Response:
          { token, tf, source: "chainstore"|"live-scan",
            pool?, backfilled?,                  # chainstore only
            candles: [{t (unix sec, bucket start), o,h,l,c (USD), v (USD)}] }
        source=chainstore requires CHAIN_STORE_ENABLED=1 (default OFF): durable
        SQLite candles from the pool's full Swap history — any tf, real
        timestamps. Otherwise live-scan: a bounded backward pool scan; only
        5m/1h/1d (other tf values are mapped to the nearest and echoed back),
        recent-window only, interpolated timestamps. First request for an
        untracked token promotes it (track+backfill) in the background when the
        store is on. Candles are bucketed trades — gaps mean no swaps, clients
        should not zero-fill.
      parameters:
        - { name: address, in: path, required: true, schema: { type: string } }
        - { name: tf, in: query, schema: { type: string, enum: [1m, 5m, 15m, 1h, 4h, 1d], default: 1h } }
        - { name: limit, in: query, schema: { type: integer, default: 60, maximum: 1000 } }
      responses: { "200": { description: "see endpoint description — prices/volume in USD" } }
  /v1/portfolio/{address}:
    get:
      summary: Wallet portfolio — slop curve holdings (own indexer) + external RHC holdings (Alchemy), with cost basis + PnL
      description: |
        THE PORTFOLIO CONTRACT (P0 buy→own→track loop). Two merged sources:
        slop CURVE tokens from our own indexer (candidates = wallet's trades ∪
        Transfer-accounting holders table; balance = authoritative on-chain
        balanceOf via the launchpad RPC, so a fresh buy shows immediately;
        priced at the curve's last trade) and external mainnet tokens from the
        Alchemy token API (+ native ETH). Deduped by address, sorted by USD
        value desc. Response:
          { address, totalUsd,
            holdings: [{ address, symbol, name, iconUrl,
                         metadataURI?,          # slop only — derive meme image
                         decimals, balance (base units, string), amount (float),
                         priceWei?,             # slop only — wei ETH per whole token
                         priceUsd, valueUsd, category,
                         slop?: true,           # tappable → /t/<address>
                         costBasisUsd?,         # slop only — flat PnL mirror of `position`
                         avgCostUsd?,           #   (USD; avgCostUsd per WHOLE token) — what
                         pnlUsd?, pnlPct?,      #   the web Holding rows render; null w/o buys
                         position?: {           # slop only — the "your position" strip
                           buys, sells,
                           ethInWei,            # total ETH paid across buys (incl. fee)
                           ethOutWei,           # total ETH received across sells (net)
                           tokensBoughtWei, tokensSoldWei,
                           avgCostWeiPerToken,  # wei ETH per whole token (avg-cost)
                           costBasisWei, costBasisUsd,
                           valueWei,
                           unrealizedEthWei, unrealizedUsd,
                           realizedEthWei, realizedUsd,
                           pnlPct               # null when nothing was bought
                         } }] }
        USD fields are null when ETH/USD is unavailable (no Alchemy key) — wei
        fields are always present, so clients can render Ξ-primary. USD PnL uses
        the CURRENT ETH/USD rate (an ETH-denominated book).
      parameters: [{ name: address, in: path, required: true, schema: { type: string } }]
      responses: { "200": { description: "see endpoint description" } }
  /v1/route:
    get:
      summary: Best route + quote for any RHC token pair (Uniswap V3 all fee tiers + V2 pairs, best-of per hop)
      description: |
        Live reads — V3 QuoterV2 across fees {100,500,3000,10000} in parallel,
        plus V2 getPair→reserves x·y·k math (HOODIE-class tokens are V2-only).
        ETH↔token is single-hop; token↔token hops through WETH. Response:
          { venue: "uniswap-v3"|"uniswap-v2"|"mixed",
            router,               # the RHC UniversalRouter — execution target
            feeTier, hops: [fee…],
            path: [{venue: "v2"|"v3", fee, pair}],  # per-hop → build commands
            multiHop, amountIn, amountOut,
            security,             # tokenized equity (server-side refuses while display-only)
            interfaceFeeBps }     # 0 while fees are OFF (no FeeRouter deployed)
        NB the RHC UniversalRouter is a fork whose swap commands take a trailing
        uint256[] minHopPriceX36 ([] = disabled) — see web/src/lib/universalRouter.ts.
      parameters:
        - { name: tokenIn, in: query, required: true, schema: { type: string } }
        - { name: tokenOut, in: query, required: true, schema: { type: string } }
        - { name: amountIn, in: query, required: true, description: base units, schema: { type: string } }
      responses:
        "200": { description: "see endpoint description" }
        "404": { description: no route found }
        "403": { description: securities display-only / geoblocked }
  /v1/health:
    get:
      summary: Liveness + data-source health (universe size/age/source, degraded flag)
      responses: { "200": { description: "{ ok, chainId, lastIndexedBlock, universe: {size, complete, ageSec, source, degraded}, sources }" } }
  /v1/profiles:
    get:
      summary: Batch username lookup for wallet addresses (feed rows, comments, creator lines)
      description: |
        Identity read path. `addresses` = comma-separated wallet addresses
        (≤100; junk entries ignored). Response keys are LOWERCASED addresses;
        wallets without a claimed profile are simply absent (clients render
        shortAddr):
          { profiles: { "0xabc…": { username, avatar } } }
      parameters:
        - { name: addresses, in: query, required: true, schema: { type: string } }
      responses: { "200": { description: "{ profiles: { [address]: { username, avatar } } }" } }
  /v1/profile:
    post:
      summary: Claim/update a username (wallet-signed, like /meta and /comments)
      description: |
        Identity write path (pump.fun-style usernames). Signed with EIP-191
        personal_sign; message = `slop: claim username @<username> at :<unix-ts>:`
        (must embed the username and a fresh ≤10-min timestamp — verified with
        the same verifyClaimed as token meta/comments). Username rule matches
        the client: /^[a-z][a-z0-9_]{2,14}$/, stored lowercase, globally unique.
        Same address re-claiming = update (avatar change / rename frees the old
        name); another address's name → 409 { error: "taken" }.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [address, message, signature, username]
              properties:
                address: { type: string }
                message: { type: string }
                signature: { type: string }
                username: { type: string }
                avatar: { type: string, description: "emoji avatar (≤16 chars)" }
      responses:
        "200": { description: "{ ok: true }" }
        "401": { description: "bad or stale signature" }
        "409": { description: "{ error: 'taken' }" }
  /v1/quote:
    get:
      summary: Quote a buy (amount = ETH wei in) or sell (amount = token base units in) against the live curve
      parameters:
        - { name: token, in: query, required: true, schema: { type: string } }
        - { name: side, in: query, required: true, schema: { type: string, enum: [buy, sell] } }
        - { name: amount, in: query, required: true, schema: { type: string } }
      responses: { "200": { description: "amountOut + feeWei" } }
  /v1/tx/launch:
    post:
      summary: Build an unsigned launch transaction (fair launch, no creator allocation)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name, symbol]
              properties:
                name: { type: string }
                symbol: { type: string }
                metadataURI: { type: string, description: "data:application/json,… with {description, image, x, telegram, website}" }
                initialBuyWei: { type: string }
      responses: { "200": { description: "{tx, signingUrl?, launchFeeWei}" } }
  /v1/tx/buy:
    post:
      summary: Build an unsigned buy transaction
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [token, ethWei]
              properties:
                token: { type: string }
                ethWei: { type: string }
                minTokensOut: { type: string }
      responses: { "200": { description: "{tx, signingUrl?}" } }
  /v1/tx/sell:
    post:
      summary: Build unsigned approve+sell transactions
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [token, tokenAmount]
              properties:
                token: { type: string }
                tokenAmount: { type: string }
                minEthOut: { type: string }
      responses: { "200": { description: "{approveTx, tx, signingUrl?}" } }
