{
 "meta": {
  "name": "Stryke API",
  "version": "v2",
  "baseUrl": "https://api.stryke.gg",
  "proxyBase": "/proxy",
  "authHeader": "X-API-Key",
  "tagline": "The Solana market-intelligence & execution API.",
  "endpointCount": 256
 },
 "groups": [
  {
   "group": "tokens",
   "groupSummary": "Token data endpoints under /api/v2/tokens. Read-through layer over the market-data layer (market details, price history, recent trades, wallet labels), the metadata service (token metadata, largest accounts), the DEX index (pairs, search, boosts/trending, embeddable charts), and Stryke's on-chain indexer, fronted by an in-process memo cache (Stryke's engine). Also exposes two heavy on-chain analysis engines: Deep Scan (Stryke's engine — a 0-100 higher=safer rug/safety verdict folding ~16 own on-chain layers plus multiple external safety providers) and the Bubble Map entity graph (Stryke's engine — weighted union-find holder/funder graph). All endpoints require the X-API-Key header (validateApiKey) and count against the per-key rate limit (apiLimiter); only the sibling /api/v2/health route is unauthenticated. Mints are validated against base58 regex ^[1-9A-HJ-NP-Za-km-z]{32,44}$. Every successful response is JSON with a top-level success:true; handler errors return success:false with an error string (most upstream failures surface as HTTP 502).",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/v2/tokens/metadata",
     "name": "Batch token metadata",
     "summary": "Returns enriched metadata (name/symbol/image/decimals/supply/ownership) for up to 100 SPL mints in one call. Lookup chain: in-process cache -> local the on-chain indexer -> the metadata service for indexer misses.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mints",
       "type": "string[]",
       "required": true,
       "description": "Array of SPL mint addresses (base58). Invalid mints are filtered out and the list is capped to the first 100 valid mints. If zero valid mints, 400 'mints[] required (max 100 valid SPL mints)'."
      }
     ],
     "responseExample": "{\"success\":true,\"count\":2,\"tokens\":{\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\":{\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"name\":\"Bonk\",\"symbol\":\"Bonk\",\"description\":null,\"image\":null,\"decimals\":5,\"supply\":\"88846160172790318\",\"ownership\":null,\"source\":\"indexer\",\"uri\":\"https://arweave.net/...\"},\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\":{\"mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"name\":\"USD Coin\",\"symbol\":\"USDC\",\"description\":null,\"image\":\"https://cdn.stryke.gg/img\",\"decimals\":6,\"supply\":\"5000000000000000\",\"ownership\":{\"frozen\":false,\"owner\":\"\"},\"source\":\"das\"}}}",
     "responseNotes": "tokens is a map keyed by mint. count = number of resolved mints (Object.keys(tokens).length); mints not found anywhere are simply absent. Each entry: mint, name, symbol, description, image, decimals, supply, ownership, source. source is 'das' for the metadata service-resolved entries (image populated from content.links.image / files cdn_uri), or 'indexer:<source>' for fast-path rows from the local token indexer (description and image always null, plus an extra uri field). Indexer rows are only used when BOTH name and symbol are populated; otherwise the mint falls through to DAS. If Stryke's node layer is not configured, indexer-covered mints are still returned and the rest are absent.",
     "errorCodes": [
      "400 mints[] required (max 100 valid SPL mints)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream provider error (success:false, error message)"
     ],
     "notes": "Cache key per mint is das:<mint>, TTL 5 min. Indexer fast-path only runs when the indexer.configured() (the platform config set). DAS batch only runs when node.configured(). PARTIAL SET: if the metadata service is unavailable, only cache + indexer-covered mints are returned (HTTP 200, partial) — unresolved mints are silently absent. Invalid mints are filtered and the input is capped to the first 100. Indexer fast-path entries carry source in the form 'indexer:<source>' and a null image.",
     "id": "post-api-v2-tokens-metadata"
    },
    {
     "method": "POST",
     "path": "/api/v2/tokens/prices",
     "name": "Batch token prices",
     "summary": "Current price + core market stats for up to 50 mints in a single call. Each mint is fetched (and cached) independently, so a partial set still returns. Cached 30s per mint.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mints",
       "type": "string[]",
       "required": true,
       "description": "Array of SPL mint addresses (base58). Invalid mints are filtered out and the list is capped to the first 50 valid mints. 400 'mints[] required (max 50 valid SPL mints)' if zero valid mints."
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"prices\":{\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\":{\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"priceUsd\":0.000004349723058551535,\"marketCap\":382777210.0797244,\"fdv\":387030958.98624325,\"liquidityUsd\":287323.0494573942,\"volume24h\":100780.78179913922,\"priceChange24h\":-2.948352950031966}}}",
     "responseNotes": "prices is an object keyed by mint → { mint, priceUsd, marketCap, fdv (fully-diluted valuation), liquidityUsd, volume24h, priceChange24h (24h %) }. count is the number of resolved entries. Mints that fail to resolve upstream are silently omitted from prices (the rest still return); compare the returned keys against your request to detect misses.",
     "errorCodes": [
      "400 mints[] required (max 50 valid SPL mints)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 market provider not configured",
      "502 upstream market-data error"
     ],
     "notes": "Per-mint cache key price:<mint>, TTL 30s — repeated batches that overlap are nearly free. Up to 50 mints are fanned out in parallel. JSON request body. PARTIAL MAP: mints that fail upstream are silently dropped — count reflects only resolved entries; diff your requested mints against the returned keys. Cached ~30s per mint.",
     "id": "post-api-v2-tokens-prices"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/trending",
     "name": "Trending tokens feed",
     "summary": "Accurate, self-policing multi-window trending from the official the DEX index REST API (boosts/profiles seed + live pair enrichment). Boost-rank primary, h24-volume tiebreak. The FULL ranked set is memoized 60s under a param-independent key; dex/minLiquidityUsd/limit are applied per-request. Carries a freshness stamp.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"generatedAt\":1782000000000,\"maxAgeMs\":60000,\"source\":\"aggregate\",\"count\":42,\"tokens\":[{\"mint\":\"6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN\",\"symbol\":\"TRUMP\",\"name\":\"OFFICIAL TRUMP\",\"image\":null,\"priceUsd\":12.34,\"priceChange\":{\"m5\":null,\"h1\":-1.2,\"h6\":3.4,\"h24\":-5.6},\"volume\":{\"m5\":1200,\"h1\":90000,\"h6\":510000,\"h24\":18450000},\"txns\":{\"m5\":{\"buys\":3,\"sells\":1},\"h1\":{\"buys\":210,\"sells\":180},\"h6\":{\"buys\":900,\"sells\":820},\"h24\":{\"buys\":4200,\"sells\":3900}},\"liquidityUsd\":9200000,\"marketCap\":2400000000,\"fdv\":12000000000,\"pairCreatedAt\":1735680000000,\"dex\":\"raydium\",\"pairAddress\":\"AbCd...\",\"labels\":[\"CLMM\"],\"boosts\":500,\"rank\":1,\"rankChange1h\":2}]}",
     "responseNotes": "Top-level: success, generatedAt (ms), maxAgeMs:60000, source:'aggregate', count (after filter/slice), tokens[]. Each token: mint, symbol, name, image, priceUsd, priceChange{m5,h1,h6,h24} (m5 often null), volume{m5,h1,h6,h24}, txns{m5:{buys,sells},h1,h6,h24}, liquidityUsd, marketCap, fdv, pairCreatedAt, dex (dexId), pairAddress, labels[], boosts, rank, rankChange1h. rank is gapless over the FULL set (dex/minLiquidity filtering may leave gaps); rankChange1h is null until ≥1h of history. This is a RICHER shape than /tokens/search. VENDOR-NEUTRAL (source:'aggregate'; vendor-CDN image hosts are dropped to null).",
     "errorCodes": [
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream the DEX index error (success:false, error message)"
     ],
     "notes": "Cache key 'trending', TTL 60s — shared across all callers. NOTE: route order — this is declared AFTER /:mint/intel etc., but Express matches the literal '/trending' segment as a path param too; '/search' and '/trending' are literal routes and win because :mint variants validate the mint regex (handlers 400 on a non-mint, so 'trending'/'search' as a :mint would fail isMint). In practice GET /api/v2/tokens/trending hits this handler. SHARED SNAPSHOT: one shared ~60s cache for ALL callers (not per-query) — every consumer gets the same snapshot; the candidate set is capped to ~60 mints before enrichment.",
     "id": "get-api-v2-tokens-trending"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/intel",
     "name": "Token intel (safety verdict + holders + risk)",
     "summary": "Combines market details (holders, supply, top10/insider %), the DEX index, and Stryke-resolved top-10 owners with entity labels into a heuristic 0+ risk score and verdict. Cached 60s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if it fails the base58 regex."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"name\":\"Bonk\",\"symbol\":\"Bonk\",\"image\":\"https://.../bonk.png\",\"price_usd\":0.00002145,\"market_cap\":1600000000,\"fdv\":1900000000,\"liquidity_usd\":1800000,\"volume_24h\":4200000,\"supply\":88846160172790,\"holders\":712345,\"top10_pct\":18.4,\"insider_pct\":2.1,\"top_holders\":[{\"owner\":\"5Q5...wQ9\",\"amount\":4200000000000,\"pct\":4.73,\"accounts\":2,\"entity\":\"Binance\",\"entityType\":\"cex\",\"flags\":[\"cex\"]}],\"risk\":{\"score\":0,\"verdict\":\"safe\",\"risks\":[]},\"pair\":{\"chain\":\"solana\",\"dex\":\"raydium\",\"address\":\"AbCd...\",\"quote\":\"SOL\"},\"generatedAt\":1718323200000}",
     "responseNotes": "Top-level (spread from cached data object): mint, name, symbol, image, price_usd, market_cap, fdv, liquidity_usd, volume_24h, supply (decimals-adjusted UI supply, decimals-aware basis preferring on-chain getTokenSupply), holders, holders_meta{value,source('provider'|'enumeration'),exact,capped,as_of}, holders_nonvault (stricter clean/non-dust count), top10_pct, insider_pct (both clamped <=100), top_holders[], taxonomies{entityType,flags}, risk{score,verdict,risks[]}, pair, generatedAt(ms). holders is NEVER a bare null when we enumerated (capped:true = page-capped lower bound -> render 'N+'). top_holders entries: owner, amount, pct (clamped <=100), accounts, entity, entityType (cex/liquidity/program/wallet/...), entity_source (provider|registry|pda|program), is_contract (true for LP/pool/program/bridge vaults — suppress 'whale'), flags[] (v2 normalized enum dev/sniper/bundler/insider/pro_trader/fresh_wallet/smart_money/whale by default; flags_taxonomy=raw returns legacy strings). risk.score is additive heuristic (top10>70 +30 / >50 +15; insiders>10 +25; bundler>15% +20; sniper>10% +10; dev>5% +15); risk.verdict = high-risk(>=50)/risky(>=25)/caution(>=10)/safe. No data-vendor name in the body (entity_source uses provider|registry|pda|program). Also returned: metadata{logo=square token icon, header=wide social/cover banner image, description, websites[], socials[], createdAt, deployer, bonded}, market, distribution, pairs[], rugScore, verdict, flags[], deepscanAvailable, checkedAt.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream provider error (success:false, error message)"
     ],
     "notes": "Cache key intel:<mint>, TTL 60s. Each upstream (market details, the DEX index, Stryke's node layer largest accounts + getMultipleAccounts owner resolution, the market-data layer wallet labels) is wrapped in.catch(()=>null) so the endpoint degrades gracefully — fields go null/empty rather than failing. Top-25 token accounts resolved -> merged by owner -> top 10 returned. DEGRADED RESPONSE: each data source is fetched independently and failures degrade field-by-field to null/empty rather than erroring — a success:true response can have null name/price and an empty top_holders. On the unlabeled path (entity data unavailable, or no resolvable owners) each top_holders entry omits `entityType` and has `entity:null`; do not assume entityType is always present.",
     "id": "get-api-v2-tokens-mint-intel"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/ath",
     "name": "All-time high / low",
     "summary": "All-time-high and all-time-low USD price for a token, with the current price's signed % distance from each. Reads the market-data layer's native athUSD/atlUSD/athDate fields; if the market-data layer lacks them, derives ATH/ATL from the full price-history series. Cached 120s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if it fails ^[1-9A-HJ-NP-Za-km-z]{32,44}$."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"price\":0.000004349726555929913,\"ath\":0.00002823877551455234,\"athDate\":\"2025-08-14T03:48:46.000Z\",\"atl\":1.195060923168786e-10,\"atlDate\":\"2025-11-12T19:34:22.700Z\",\"fromAthPct\":-84.59661767668233,\"fromAtlPct\":3639652.9796190765,\"priceChange24h\":-2.948352950031966}",
     "responseNotes": "price (current USD), ath / atl (all-time high/low USD), athDate / atlDate (ISO 8601 timestamps), fromAthPct / fromAtlPct (signed % distance of the current price from ATH/ATL — fromAthPct is ≤0, fromAtlPct ≥0), priceChange24h (24h % change). Values are all-time (full history), not windowed. When the native ATH/ATL is missing the fields are derived from the complete price series and the dates are emitted as ISO strings.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 market provider not configured",
      "502 upstream market-data error"
     ],
     "notes": "Cache key ath:<mint>, TTL 120s. One market-details call in the common case; a second price-history call only when ATH/ATL must be derived. fromAthPct/fromAtlPct are null unless BOTH the current price and the respective ATH/ATL are present and non-zero.",
     "id": "get-api-v2-tokens-mint-ath"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/trades",
     "name": "Recent trades (live tape)",
     "summary": "Recent swaps on a mint's primary pair from the market-data layer, normalised into a compact trade-tape shape. Cached 30s per (mint,limit).",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "20",
       "description": "Number of trades. Number(limit)||20, clamped to [1,100]."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"count\":2,\"trades\":[{\"hash\":\"5xT...abc\",\"date\":\"2026-06-13T22:14:05.000Z\",\"amountUSD\":1240.55,\"priceUSD\":0.00002145,\"amount\":57835664.1,\"side\":\"buy\",\"sender\":\"9aB...xy\",\"platform\":\"raydium\"},{\"hash\":\"7pQ...def\",\"date\":\"2026-06-13T22:13:51.000Z\",\"amountUSD\":310.2,\"priceUSD\":0.00002143,\"amount\":14475000.0,\"side\":\"sell\",\"sender\":\"3Cd...zz\",\"platform\":\"raydium\"}]}",
     "responseNotes": "Top-level: mint, count (trades.length), trades[]. Each trade: hash (t.hash||t.transaction_hash), date, amountUSD (Number of token_amount_usd||amount_usd, 0 if absent), priceUSD (token_price||price_usd_token0||price_usd_token1), amount (token_amount), side (lowercased type/side e.g. 'buy'/'sell'), sender (sender||transaction_sender_address), platform. Returns 503 (success:false, 'trades provider not configured') when the market-data layer key is absent.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 trades provider not configured (the market-data layer key missing)",
      "502 upstream the market-data layer error (success:false, error message)"
     ],
     "notes": "Cache key trades:<mint>:<limit>, TTL 30s. the market-data layer upstream (market/trades/pair) is known to intermittently 503; here it surfaces as a 502 wrapper since getRecentTrades throws on non-ok. amountUSD/priceUSD/amount default to 0 when missing or unparseable — a 0 can mean genuinely zero OR unavailable; limit clamped [1,100].",
     "id": "get-api-v2-tokens-mint-trades"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/deepscan",
     "name": "Deep Scan (token safety / rug verdict)",
     "summary": "Runs the full Stryke Deep Scan engine: ~16 own on-chain layers (contract/holders/liquidity/bundle/creator/market/launch/holderstats/cohorts/lplock/honeypot/swapstats/devforensics/sniperforensics/programs/bubblemap) cross-validated by an external safety provider (always) plus an external safety provider/an external market provider/the holder-graph provider/an external market provider (when keyed), folded into one 0-100 higher=safer score, grade, verdict and flagged risks. Cached 120s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid. Canonicalized (trimmed) internally."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"score\":88,\"grade\":\"safe\",\"verdict\":\"Looks safe — only minor flags\",\"summary\":\"Mature blue-chip; diffuse holder distribution; LP burned.\",\"layers\":{\"contract\":{\"ok\":true,\"checks\":[{\"id\":\"mint_authority\",\"ok\":true,\"severity\":\"info\",\"label\":\"Mint authority revoked\",\"detail\":\"...\"}],\"errors\":[]},\"holders\":{\"ok\":true,\"checks\":[],\"errors\":[]},\"liquidity\":{\"ok\":true,\"checks\":[],\"errors\":[]},\"bundle\":{\"ok\":true,\"checks\":[],\"errors\":[]},\"creator\":{\"ok\":true,\"checks\":[],\"errors\":[]},\"market\":{\"ok\":true,\"checks\":[],\"errors\":[]},\"launch\":{\"ok\":true,\"checks\":[]},\"holderstats\":{\"ok\":true,\"checks\":[]},\"cohorts\":{\"ok\":true,\"checks\":[]},\"lplock\":{\"ok\":true,\"checks\":[]},\"honeypot\":{\"ok\":true,\"checks\":[]},\"swapstats\":{\"ok\":true,\"checks\":[]},\"devforensics\":{\"ok\":true,\"checks\":[]},\"sniperforensics\":{\"ok\":true,\"checks\":[]},\"programs\":{\"ok\":true,\"checks\":[],\"safeProgramOwners\":[]},\"bubblemap\":{\"ok\":true,\"checks\":[]}},\"providers\":{\"an external safety provider\":{\"ok\":true,\"available\":true,\"scoreSafe0to100\":90,\"checks\":[],\"errors\":[]},\"an external safety provider\":{\"ok\":false,\"available\":false,\"scoreSafe0to100\":null,\"checks\":[],\"errors\":[],\"_notConfigured\":true}},\"flags\":[{\"id\":\"top10_concentration\",\"source\":\"holders\",\"ok\":false,\"severity\":\"medium\",\"label\":\"...\",\"detail\":\"...\"}],\"crossValidation\":{\"an external safety providerSafe\":90,\"agree\":true,\"note\":null},\"weights\":{\"startScore\":100,\"additiveScore\":0,\"totalFailing\":1,\"hardCapped\":false,\"hardCapReasons\":[],\"mature\":true,\"whitelisted\":false},\"entity\":{\"decentralizationScore\":82,\"largestClusterPct\":6.2,\"entityCount\":3,\"clusteredPct\":12.4,\"hiddenSupplyPct\":1.1,\"entity_nakamoto\":14,\"entity_gini\":0.61,\"nodeCount\":120,\"linkCount\":210},\"generatedAt\":1718323200000}",
     "responseNotes": "Top-level (spread from deepScan result): mint (canonical), score (0-100, higher=safer), grade ('safe'>=85 | 'caution'>=60 | 'risky'>=35 | 'danger'<35; whitelist/stablecoin overrides exist), verdict (human string e.g. 'No issues found','Caution — has critical flags','Danger — likely rug / unsafe'), summary (string), layers (object keyed by layer name; each is a report {ok, checks[], errors[]} — failed/missing layers become {ok:false, checks:[], errors:[...], _stub:true}; each check is {id, ok, severity('critical'|'high'|'medium'|'warning'|'low'|'info'), label, detail}), providers (multiple external safety providers; each {ok, available, scoreSafe0to100, checks[], errors[]}; unkeyed providers are {ok:false, available:false, scoreSafe0to100:null, _notConfigured:true}), flags[] (every failing check across all layers+providers, sorted critical->info then by source), crossValidation {an external safety providerSafe, agree, note}, weights (scoring internals: startScore, severityPenalty, additiveScore, failingCounts, totalFailing, hardCapped, hardCapReasons, honeypotConfirmed, entityAdjusted, mature, whitelisted, regulatedStablecoin,...), entity (compact bubblemap summary {decentralizationScore, largestClusterPct, entityCount, clusteredPct, hiddenSupplyPct, entity_nakamoto, entity_gini, nodeCount, linkCount} or null), generatedAt(ms).",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 deepScan/upstream error (success:false, error message)"
     ],
     "notes": "Cache key deepscan:<mint>, TTL 120s. Heaviest endpoint — the bundle funding-graph trace dominates cost (the Stryke frontend lazy-loads it on scroll-into-view). Engine is fail-soft: every layer/provider runs under Promise.allSettled and degrades to an error stub rather than failing the scan; a complete verdict is produced even with all third-party providers disabled. The full node/link graph is NOT here — use /{mint}/bubblemap. FAIL-SOFT: individual safety layers degrade independently to a stub rather than erroring, so a success:true verdict can be built on partial inputs; whitelist/stablecoin overrides can floor or force the grade for known blue-chips.",
     "id": "get-api-v2-tokens-mint-deepscan"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/bubblemap",
     "name": "Bubble Map entity graph",
     "summary": "Returns the unified holder/funder entity graph (weighted union-find entity resolution) — nodes, links (typed + weighted with evidence), clusters, plus decentralization/concentration summary. Replaces the holder-graph provider + InsightX Atlas + an external safety provider insider graph. Cached 120s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid. Canonicalized internally."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"ok\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"nodes\":[{\"id\":\"5Q5...wQ9\",\"kind\":\"holder\",\"label\":\"Binance\",\"pctSupply\":4.7312,\"balance\":4200000000000,\"rank\":1,\"flags\":[\"cex\"],\"entity_id\":\"5Q5...wQ9\",\"degree\":3,\"is_cex\":true,\"is_dex\":false,\"is_lp\":false,\"is_supernode\":false,\"fresh_wallet\":false,\"first_buy_slot\":null,\"age_sec\":null}],\"links\":[{\"source\":\"AaA...1\",\"target\":\"BbB...2\",\"type\":\"same_funder\",\"weight\":0.8,\"evidence\":{\"funder\":\"Cc...3\"}}],\"clusters\":[{\"entity_id\":\"ent_1\",\"kind\":\"bundle\",\"memberCount\":4,\"pctSupply\":8.21,\"rootFunder\":\"Cc...3\",\"collector\":null,\"devLinked\":false,\"confidence\":0.74,\"kinds\":[\"same_funder\"],\"evidence\":[],\"members\":[\"AaA...1\",\"BbB...2\"]}],\"decentralizationScore\":82,\"largestClusterPct\":8.21,\"summary\":{\"entityCount\":3,\"nodeCount\":120,\"linkCount\":210,\"clusteredPct\":12.4,\"hiddenSupplyPct\":1.1,\"gini\":0.71,\"hhi\":0.04,\"nakamoto\":12,\"entity_gini\":0.61,\"entity_hhi\":0.05,\"entity_nakamoto\":11,\"decimals\":5,\"holdersTotal\":250},\"checks\":[{\"id\":\"entity_concentration\",\"ok\":true,\"severity\":\"info\",\"label\":\"Diffuse entity distribution\",\"detail\":\"3 entities; largest 8.2% of float.\"}],\"errors\":[]}",
     "responseNotes": "Top-level (spread from analyzeBubblemap report): ok (bool), mint (canonical), nodes[], links[], clusters[], decentralizationScore (0-100 or null), largestClusterPct (number), summary{...}, checks[], errors[]. node: {id, kind, label, pctSupply, balance, rank, flags[], entity_id, degree, is_cex, is_dex, is_lp, is_supernode, fresh_wallet, first_buy_slot, age_sec} sorted by pctSupply desc then degree. link: {source, target, type (edge type e.g. same_funder/same_slot/transfer/peel), weight (number or null from EDGE_WEIGHTS), evidence (object — the WHY)}. cluster: {entity_id, kind, memberCount, pctSupply, rootFunder, collector, devLinked, confidence, kinds[], evidence[], members[]}. summary carries both raw and entity-collapsed concentration metrics (gini/hhi/nakamoto + entity_gini/entity_hhi/entity_nakamoto), clusteredPct, hiddenSupplyPct, decimals (client scales raw balances), holdersTotal (distinct owners found -> 'top N of M'). On invalid/unbuildable graph the report returns ok:false with empty nodes/links/clusters and an info check. analyzeBubblemap never throws.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 wrapper error (success:false, error message)"
     ],
     "notes": "Cache key bubblemap:route:<mint>, TTL 120s (the inner build memoizes bubblemap:<mint> separately). Links are capped at MAX_LINKS. Reuses memoized launch:/bundle: traces from the deepscan layers when warm so it pays no extra fetch for same-slot/funding edges — the only live work is the bounded transfer-edge scan (skippable via env). never errors on an unbuildable graph — returns ok:false with empty nodes/links (HTTP 200); links are capped and nodes are sorted by supply share.",
     "id": "get-api-v2-tokens-mint-bubblemap"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/safety",
     "name": "Safety chip (canonical, list-cheap)",
     "summary": "Compact per-token safety summary (0-100 HIGHER=SAFER) cheap enough to fan out across a whole list (Radar/Oracle/cards). Derives from a warm deepscan (status complete) else a cheap intel chip (status partial); never runs a fresh full deepscan. Cached 60s. A batch form GET /api/v2/tokens/safety?mints=a,b,c (<=30) returns {success,results:{<mint>:chip}}.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL mint (base58). 400 'invalid mint' if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "flags",
       "type": "integer",
       "required": false,
       "default": "3",
       "description": "Max topGreen and topRed each (clamped 1-5)."
      },
      {
       "name": "refresh",
       "type": "boolean",
       "required": false,
       "default": "false",
       "description": "Bust ONLY the 60s safety memo (never triggers a full deepscan)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"score\":90,\"grade\":\"safe\",\"verdict\":\"Verified blue-chip — safe\",\"topGreen\":[{\"id\":\"mint_authority\",\"label\":\"Mint authority renounced\",\"detail\":\"Supply cannot be inflated\",\"severity\":\"info\"}],\"topRed\":[],\"hardCapped\":false,\"hardCapReason\":null,\"venues\":[{\"dex\":\"meteora\",\"dexId\":\"meteora\",\"iconKey\":\"meteora\",\"pairAddress\":\"7ee...T2\",\"primary\":true}],\"primaryVenue\":\"meteora\",\"liquidityUsd\":1715749,\"scannedAt\":1782000000000,\"source\":\"deepscan\",\"status\":\"complete\"}",
     "responseNotes": "score 0-100 HIGHER=SAFER (== deepscan.score when warm). grade from gradeFor() (>=85 safe / >=60 caution / >=35 risky / else danger). topGreen/topRed entries {id,label,detail,severity(critical|high|medium|low|info)} are OWN-LAYER checks ONLY — vendor-named provider echoes are excluded, so NO data-vendor name appears in the body. venues deduped by dexId, primary=highest-h24-vol. source: deepscan (warm full scan, status complete) | intel (cheap chip, partial) | venues (status pending). NEVER 5xx — a failing mint degrades to status pending.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded"
     ],
     "notes": "Cache key safety:<mint>, TTL 60s. Reads the warm deepscan:/intel: caches (never a fresh deepscan). Venue list via the DEX index (not the v2 key, so it does not touch the per-key breaker). Batch: GET /api/v2/tokens/safety?mints=a,b,c (<=30, per-mint degrade).",
     "id": "get-api-v2-tokens-mint-safety"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/search",
     "name": "Token search (superset)",
     "summary": "Autocomplete token search by name/symbol/address. SUPERSET of the old search entry — each result now carries full card data: image/header/description, decimals, socials_list, websites_list, multi-window priceChange/volume/txns, buy/sell counts, marketCap/fdv/liquidityUsd, pairAddress/dexId, boosts, age, base/quote tokens and DEX labels. Replaces any prior /tokens/search entry.",
     "authRequired": true,
     "queryParams": [
      {
       "name": "q",
       "type": "string",
       "description": "Search query (name, symbol, or mint). Minimum 2 chars; shorter returns an empty results array.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"results\":[{\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"symbol\":\"BONK\",\"name\":\"Bonk\",\"image\":\"https://dd.the DEX index.com/ds-data/tokens/solana/Dez...png\",\"url\":\"https://the DEX index.com/solana/8...\",\"priceUsd\":0.00002413,\"priceChange\":{\"m5\":0.1,\"h1\":-0.4,\"h6\":1.2,\"h24\":5.6},\"volume24h\":18650000,\"liquidityUsd\":7420000,\"marketCap\":1820000000,\"fdv\":1820000000,\"pair\":{\"chain\":\"solana\",\"dex\":\"raydium\",\"address\":\"8...\",\"quote\":\"SOL\"},\"socials\":{\"twitter\":\"https://x.com/bonk_inu\"},\"websites\":[\"https://bonkcoin.com\"],\"header\":\"https://dd.the DEX index.com/.../header.png\",\"description\":\"The community dog coin of Solana.\",\"decimals\":5,\"socials_list\":[{\"type\":\"twitter\",\"url\":\"https://x.com/bonk_inu\"}],\"websites_list\":[{\"url\":\"https://bonkcoin.com\"}],\"volume\":{\"m5\":12000,\"h1\":410000,\"h6\":3100000,\"h24\":18650000},\"txns\":{\"m5\":{\"buys\":40,\"sells\":31},\"h1\":{\"buys\":900,\"sells\":820},\"h6\":{\"buys\":5400,\"sells\":5100},\"h24\":{\"buys\":21000,\"sells\":19500}},\"buys24h\":21000,\"sells24h\":19500,\"pairCreatedAt\":1672531200000,\"ageHours\":12480.5,\"boosts\":0,\"dexId\":\"raydium\",\"pairAddress\":\"8...\",\"baseToken\":{\"address\":\"Dez...\",\"name\":\"Bonk\",\"symbol\":\"BONK\"},\"quoteToken\":{\"symbol\":\"SOL\",\"address\":\"So111...112\"},\"labels\":[]}]}",
     "responseNotes": "results[] — up to 20 mints, deduped by mint keeping the highest-h24-volume pair. Base fields (mint, symbol, name, image, url, priceUsd, priceChange, volume24h, liquidityUsd, marketCap, fdv, pair{}, socials{}, websites[]) come from the the DEX index shaper; the superset fields (header, description, decimals, socials_list, websites_list, volume{m5,h1,h6,h24}, txns{...}{buys,sells}, buys24h, sells24h, pairCreatedAt, ageHours, boosts, dexId, pairAddress, baseToken, quoteToken, labels) are layered on top. USD/price fields are glitch-clamped (price ≤ 1e7, USD ≤ 1e12). decimals is backfilled from the local indexer then a single batched the metadata service call. Cached 60s.",
     "errorCodes": [
      {
       "code": 502,
       "when": "the DEX index (or DAS backfill) upstream failed"
      }
     ],
     "notes": "q under 2 chars returns {success:true,results:[]} (HTTP 200), never an error.",
     "id": "tokens-search"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/trending/raw",
     "name": "Trending tokens (raw, tagged)",
     "summary": "Merged, deduped and enriched trending feed built from the DEX index boosts/top + boosts/latest + profiles/latest, resolved to full pairs. Each token carries source tags, dex_rank, raw (un-neutralized) image, rich price/volume/txn windows and boost amount — so a consumer can build gainers/losers/new/trending/promoted lists itself.",
     "authRequired": true,
     "responseExample": "{\"success\":true,\"generatedAt\":1719446400000,\"maxAgeMs\":60000,\"count\":2,\"tokens\":[{\"mint\":\"7xKp...moon\",\"symbol\":\"MOON\",\"name\":\"Moon\",\"image\":\"https://dd.the DEX index.com/.../moon.png\",\"url\":\"https://the DEX index.com/solana/AbCd\",\"priceUsd\":0.0123,\"priceChange\":{\"m5\":2.1,\"h1\":8.4,\"h6\":40.2,\"h24\":120.5},\"volume24h\":540000,\"liquidityUsd\":210000,\"marketCap\":1230000,\"fdv\":1230000,\"pair\":{\"chain\":\"solana\",\"dex\":\"raydium\",\"address\":\"AbCd\",\"quote\":\"SOL\"},\"socials\":{\"telegram\":\"https://t.me/moon\"},\"websites\":[\"https://moon.fun\"],\"header\":\"https://dd.the DEX index.com/.../header.png\",\"description\":\"To the moon.\",\"decimals\":6,\"socials_list\":[{\"type\":\"telegram\",\"url\":\"https://t.me/moon\"}],\"websites_list\":[{\"url\":\"https://moon.fun\"}],\"volume\":{\"m5\":8000,\"h1\":90000,\"h6\":320000,\"h24\":540000},\"txns\":{\"m5\":{\"buys\":60,\"sells\":20},\"h1\":{\"buys\":700,\"sells\":300},\"h6\":{\"buys\":2400,\"sells\":1100},\"h24\":{\"buys\":4800,\"sells\":2600}},\"buys24h\":4800,\"sells24h\":2600,\"pairCreatedAt\":1719360000000,\"ageHours\":24.0,\"boosts\":500,\"dexId\":\"raydium\",\"pairAddress\":\"AbCd\",\"baseToken\":{\"address\":\"7xKp...moon\",\"name\":\"Moon\",\"symbol\":\"MOON\"},\"quoteToken\":{\"symbol\":\"SOL\",\"address\":\"So111...112\"},\"labels\":[],\"dex_rank\":1,\"tags\":[\"boosted\",\"new\"],\"source\":\"aggregate\"}]}",
     "responseNotes": "tokens[] — same superset shape as /tokens/search results PLUS: image (raw the DEX index/boost icon, NOT neutralized), boosts (max boost amount seen), dex_rank (rank in the boosts/top list, null if only seen in latest/profiles feeds), tags[] (subset of 'boosted','recent_boost','new'), source ('aggregate'). Up to 90 seed mints resolved; tokens without a resolvable pair are dropped. Sorted by boosts desc then h24 volume desc. generatedAt is epoch ms; maxAgeMs is the 60s cache window. USD/price glitch-clamped.",
     "errorCodes": [
      {
       "code": 502,
       "when": "Trending feed unavailable (the DEX index boosts/profiles upstream failed) — body is {success:false,error:\"trending unavailable\"}"
      }
     ],
     "id": "tokens-trending-raw"
    },
    {
     "method": "POST",
     "path": "/api/v2/tokens/pairs-batch",
     "name": "Pairs batch (full the DEX index)",
     "summary": "Returns the full, glitch-sanitized primary the DEX index pair object for up to 50 mints in one call — every price/volume/txn window plus liquidity, marketCap/fdv and info{imageUrl,header,description,socials,websites}. For consumers that build their own cards. Per-mint 30s cache.",
     "authRequired": true,
     "bodyParams": [
      {
       "name": "mints",
       "type": "string[]",
       "description": "Array of SPL mint addresses. Invalid entries are dropped; capped at the first 50 valid mints.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"pairs\":{\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\":{\"chainId\":\"solana\",\"dexId\":\"raydium\",\"pairAddress\":\"8B...\",\"url\":\"https://the DEX index.com/solana/8B\",\"baseToken\":{\"address\":\"Dez...\",\"name\":\"Bonk\",\"symbol\":\"BONK\"},\"quoteToken\":{\"address\":\"So111...112\",\"name\":\"Wrapped SOL\",\"symbol\":\"SOL\"},\"priceUsd\":0.00002413,\"priceNative\":0.00000016,\"priceChange\":{\"m5\":0.1,\"h1\":-0.4,\"h6\":1.2,\"h24\":5.6},\"volume\":{\"m5\":12000,\"h1\":410000,\"h6\":3100000,\"h24\":18650000},\"txns\":{\"m5\":{\"buys\":40,\"sells\":31},\"h1\":{\"buys\":900,\"sells\":820},\"h6\":{\"buys\":5400,\"sells\":5100},\"h24\":{\"buys\":21000,\"sells\":19500}},\"liquidity\":{\"usd\":7420000,\"base\":150000000000,\"quote\":24000},\"marketCap\":1820000000,\"fdv\":1820000000,\"pairCreatedAt\":1672531200000,\"labels\":[],\"boosts\":{\"active\":0},\"info\":{\"imageUrl\":\"https://dd.the DEX index.com/.../bonk.png\",\"header\":\"https://dd.the DEX index.com/.../header.png\",\"description\":\"The community dog coin of Solana.\",\"socials\":[{\"type\":\"twitter\",\"url\":\"https://x.com/bonk_inu\"}],\"websites\":[{\"url\":\"https://bonkcoin.com\"}]}}}}",
     "responseNotes": "pairs is a map keyed by mint → full pair object (or null if no pair found for that mint). count is the number of non-null pairs. When multiple pairs exist for a mint the highest-h24-volume one wins. All USD/price fields glitch-clamped (price ≤ 1e7, USD ≤ 1e12). labels capped to 8. Each mint cached 30s independently, so overlapping batches share hits.",
     "errorCodes": [
      {
       "code": 400,
       "when": "mints[] missing/empty or contains no valid mints — error \"mints[] required (max 50 valid mints)\""
      },
      {
       "code": 502,
       "when": "the DEX index batch upstream failed"
      }
     ],
     "id": "tokens-pairs-batch"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/metadata-full",
     "name": "Token metadata (enriched)",
     "summary": "Off-chain enriched metadata for a Solana token: categories/tags, VC investors, supply allocation %, global rank, and a richer socials block. Curated off-chain (the market-data layer Metacore) — best for established tokens; sparse for fresh mints.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Solana token mint (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezX...B263\",\"name\":\"Bonk\",\"symbol\":\"BONK\",\"rank\":127,\"decimals\":5,\"priceUSD\":0.00000403,\"marketCapUSD\":354686610.69,\"liquidityUSD\":68408.71,\"volume24hUSD\":338600.57,\"totalSupply\":89187752916383,\"circulatingSupply\":87987140074620,\"maxSupply\":93526183276778,\"description\":\"\",\"logo\":\"https://...\",\"categories\":[\"Meme\"],\"investors\":[],\"distribution\":[],\"socials\":{\"twitter\":\"https://twitter.com/bonk_inu\",\"website\":\"https://www.bonkcoin.com\",\"discord\":null,\"telegram\":\"https://t.me/Official_Bonk_Inu\"},\"otherChains\":[\"Ethereum\",\"Polygon\",\"Arbitrum\"]}",
     "id": "get-api-v2-tokens-mint-metadata-full",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/screener",
     "name": "Token screener / search",
     "summary": "Search Solana tokens sortable by trendingScore24h, volume24h, liquidity, holdersCount, createdAt, or organicVolume1h (bot-filtered volume). A screener view over the token universe.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "input",
       "type": "string",
       "required": true,
       "description": "Search term (symbol / name fragment)."
      },
      {
       "name": "sortBy",
       "type": "string",
       "required": false,
       "default": "volume24h",
       "description": "Sort field: trendingScore24h, volume24h, liquidity, holdersCount, createdAt, organicVolume1h."
      },
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "10",
       "description": "Results to return. Max 20."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"input\":\"bonk\",\"sortBy\":\"organicVolume1h\",\"count\":1,\"results\":[{\"mint\":\"BonkUg9t...hwWY\",\"symbol\":\"Bonk\",\"name\":\"Bonk\",\"decimals\":6,\"priceUSD\":0.00000656,\"marketCapUSD\":3279723.35,\"liquidityUSD\":50.13,\"volume24hUSD\":127909.6,\"holders\":85,\"logo\":\"https://...\"}]}",
     "id": "get-api-v2-tokens-screener",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/dev-history",
     "name": "Dev / deployer history",
     "summary": "The creator/deployer wallet behind a mint plus its track record: prior launches, rug-rate, serial-/fast-rugger verdicts, dev-sold class, and funding-origin forensics (terminal funder / CEX, privacy-peel hops, operator ring). Thin wrapper over the dev-forensics engine that is also folded into /deepscan; self-resolves the deployer, so it is standalone-safe. Route memoized 2 min.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL mint (base58). 400 \"invalid mint\" if invalid."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"ok\":true,\"dev\":\"B4sAAVYE9v5iadYXdNYUdD1LoqtJi7xELJiGvfLwkmJD\",\"devSource\":\"bonding_curve\",\"priorTokens\":0,\"ruggedPriorTokens\":0,\"rugRate\":0,\"peakPriorValueUsd\":null,\"serialRugger\":false,\"medianTimeToRugSecs\":null,\"medianTimeToRugSamples\":null,\"fastRugger\":false,\"devSoldPct\":null,\"devSoldClass\":null,\"devRootFunder\":null,\"devFunderTerminal\":{\"address\":\"BmFdpraQhkiDQE6SnfG5omcA1VwzqfXrwtNYBwWTymy6\",\"name\":\"KuCoin\",\"category\":\"cex\"},\"devFunderPeeled\":false,\"devFunderPeel\":null,\"privacyHops\":0,\"operatorRing\":null,\"operatorRingDetected\":false,\"ringCoLaunchBurst\":null,\"devFunderPeelWeak\":null,\"checks\":[{\"id\":\"dev_first_launch\",\"ok\":false,\"severity\":\"low\",\"label\":\"First launch from deployer\",\"detail\":\"No prior launch history — no track record yet.\"},{\"id\":\"dev_funder_cex\",\"ok\":true,\"severity\":\"info\",\"label\":\"Deployer funded from a CEX\",\"detail\":\"Deployer funded from KuCoin — organic on-chain origin.\"}],\"errors\":[]}",
     "responseNotes": "dev is the resolved deployer; devSource is how it was resolved (e.g. \"bonding_curve\"). priorTokens/ruggedPriorTokens/rugRate summarize the deployer's earlier launches; serialRugger + fastRugger + medianTimeToRugSecs are behavioural verdicts (null when there is no prior history). devSoldPct/devSoldClass describe how much of THIS token the dev has offloaded. Funding origin: devFunderTerminal {address,name,category} is the terminal source (category e.g. \"cex\"); devFunderPeeled/privacyHops/devFunderPeel flag peel-chain obfuscation; operatorRing/operatorRingDetected/ringCoLaunchBurst flag a coordinated multi-wallet launch ring. checks[] are own-layer signals {id, ok (true=good), severity(critical|high|medium|low|info), label, detail}. errors[] lists any sub-analysis that degraded. ok:true means the report assembled (individual fields may still be null on a thin on-chain history).",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 { success:false, error } — upstream/on-chain analysis failure"
     ],
     "notes": "Cache key devhistory:route:<mint>, TTL 120s (the engine does not self-cache the full report). Same engine surfaced inside the /deepscan verdict; call this endpoint when you want ONLY the deployer dossier without a full scan. Heavy on-chain (multi-page enhanced-tx + prior-launch liveness) — expect ~seconds on a cold mint.",
     "id": "get-api-v2-tokens-mint-dev-history"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/snipers",
     "name": "Launch sniper cohort",
     "summary": "The launch-sniper cohort for a mint: how many wallets sniped the launch, what share of supply they took, slot-0 vs cohort counts, their exit distribution (still holding / sold partial / sold full / bought more), bundle concentration, and a sampled per-sniper farming profile (serial-sniper / heavy-farmer flags). Combines the launch-tape analysis with sniper forensics (which reads the same tape). Route memoized 2 min.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL mint (base58). 400 \"invalid mint\" if invalid."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"launchSlot\":431725260,\"launchTime\":1783569359,\"sniperCount\":70,\"sniperPct\":11.72,\"slot0SniperCount\":7,\"slot0SniperPct\":10.6,\"cohortSniperCount\":70,\"sniperExit\":{\"classified\":70,\"holding\":2,\"soldPartial\":1,\"soldFull\":67,\"boughtMore\":0},\"bundledPct\":99.33,\"bundleWalletCount\":70,\"sampledSnipers\":[{\"address\":\"F7RV6aBWfniixoFkQNWmRwznDj2vae2XbusFfvMMjtbE\",\"fungibles\":49,\"serial\":false,\"heavy\":true},{\"address\":\"HsBK95XGUpVD3ENufWapD3Jib6zodHE6xj3VLArNBX3v\",\"fungibles\":28,\"serial\":false,\"heavy\":false}],\"serialSniperRatio\":0}",
     "responseNotes": "launchSlot/launchTime anchor the launch. sniperCount/sniperPct = wallets that bought in the sniper window and the % of supply they took; slot0SniperCount/slot0SniperPct = the subset that landed in the launch slot; cohortSniperCount = the cohort size scored by forensics. sniperExit {classified, holding, soldPartial, soldFull, boughtMore} partitions the cohort by current disposition. bundledPct/bundleWalletCount measure how much of the cohort landed in bundled (same-block/coordinated) transactions. sampledSnipers[] is a SAMPLE (not the full cohort): {address, fungibles (# of other fungible tokens the wallet holds — a farming signal), serial (repeat sniper), heavy (heavy farmer)}. serialSniperRatio is the sampled share flagged as serial snipers. Forensics runs after the launch pass (it consumes the tape the launch pass populates); if forensics degrades, sampledSnipers is [] and serialSniperRatio is null while the launch aggregates still return.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 { success:false, error } — launch/forensics analysis failure"
     ],
     "notes": "Cache key snipers:route:<mint>, TTL 120s. Same launch+sniper engine folded into /deepscan; use this for ONLY the sniper cohort. Heavy on-chain (launch tape + per-sniper sampling) — seconds on a cold mint.",
     "id": "get-api-v2-tokens-mint-snipers"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/safety",
     "name": "Safety chips (batch)",
     "summary": "Batch form of the per-token safety chip (0-100 HIGHER=SAFER): fan a comma-separated mint list (max 30) into one call and get a map of chips keyed by mint. Each chip derives from a warm deepscan (status complete) else a cheap intel chip (status partial); it NEVER runs a fresh full deepscan. Per-mint degrade — a failing mint returns a pending/error chip instead of failing the batch. Sibling of GET /api/v2/tokens/{mint}/safety (single).",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "mints",
       "type": "string",
       "required": true,
       "description": "Comma-separated SPL mints (base58). Invalid mints are dropped; the list is capped to the first 30 valid mints. 400 \"mints required …\" if none are valid."
      },
      {
       "name": "flags",
       "type": "integer",
       "required": false,
       "default": "3",
       "description": "Max topGreen and topRed each per chip (clamped 1-5)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"results\":{\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\":{\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"score\":90,\"grade\":\"safe\",\"verdict\":\"Verified blue-chip — safe\",\"topGreen\":[{\"id\":\"mint_authority\",\"label\":\"Mint authority renounced\",\"detail\":\"Supply cannot be inflated\",\"severity\":\"info\"}],\"topRed\":[],\"venues\":[{\"dex\":\"meteora\",\"dexId\":\"meteora\",\"iconKey\":\"meteora\",\"pairAddress\":\"7ee…T2\",\"primary\":true}],\"liquidityUsd\":1715749,\"source\":\"deepscan\",\"status\":\"complete\"},\"So11111111111111111111111111111111111111112\":{\"mint\":\"So11111111111111111111111111111111111111112\",\"status\":\"pending\",\"score\":null,\"grade\":null,\"topGreen\":[],\"topRed\":[],\"venues\":[],\"source\":\"error\"}}}",
     "responseNotes": "results is a map<mint, chip>. Each chip matches the single-mint /safety body minus the top-level success: {mint, score (0-100 HIGHER=SAFER), grade (safe|caution|risky|danger), verdict, topGreen/topRed ({id,label,detail,severity(critical|high|medium|low|info)} — OWN-LAYER checks only, no vendor names), venues (deduped by dexId, primary=highest 24h vol), liquidityUsd, source (deepscan|intel|venues|error), status (complete|partial|pending)}. topGreen/topRed are each sliced to `flags`. A mint that throws degrades to {status:\"pending\", score:null, grade:null, …, source:\"error\"} — never a 5xx.",
     "errorCodes": [
      "400 mints required (comma-separated, max 30 valid SPL mints)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded"
     ],
     "notes": "Each chip reads the warm deepscan:/intel: caches (60s safety memo per mint); this endpoint NEVER triggers a fresh full deepscan, so it is cheap enough to fan across a whole list (Radar/Oracle/cards). Static 1-segment path — does not collide with the 2-segment /{mint}/safety. NOTE: currently this batch form is also described inline in the notes of GET /api/v2/tokens/{mint}/safety; this is its standalone entry.",
     "id": "get-api-v2-tokens-safety-batch"
    }
   ],
   "label": "Tokens",
   "icon": "coin"
  },
  {
   "group": "wallets",
   "groupSummary": "Wallet data endpoints — the core of the v2 API for portfolio dashboards, tax tools, and wallet explorers. Mounted at /api/v2/wallets (router built in the API layer, mounted via app.use('/api/v2',...) in the API layer). All endpoints require a valid X-API-Key header (validateApiKey middleware) and count against a per-key rate limit (apiLimiter); only the sibling /api/v2/health route is unauthenticated. Telemetry middleware (Stryke's engine) logs every request — including failed-auth attempts — before auth runs. Address params are validated against a base58 regex /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; invalid addresses return 400 {success:false,error:'invalid wallet'}. Provider-not-configured states return 503. Upstream provider failures are caught and returned as 502 {success:false,error:<msg>}. Data sources: the market-data layer (portfolio, history, trades, activity) and Stryke's node layer (enhanced/parsed transactions, DAS metadata, RPC). The heavy /profile, /wrapped, and /roast endpoints share a 5-minute server-side cache keyed by depth+wallet(+since), set public Cache-Control with stale-while-revalidate, and build on profile.the assembler (≤5 Stryke's node layer enhanced-tx pages + 1 the market-data layer portfolio on a cold cache).",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/portfolio",
     "name": "Wallet portfolio (token holdings)",
     "summary": "Returns the wallet's live token holdings with USD values, prices, 24h change and allocation. Primary source is the market-data layer's portfolio endpoint; falls back to the metadata service getAssetsByOwner (including native SOL) when the market-data layer isn't configured.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58, 32-44 chars). Validated by SOL_ADDR_RE; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "fresh",
       "type": "boolean",
       "required": false,
       "default": "false",
       "description": "Pass fresh=true to bypass the 30s server-side cache and force a live provider fetch. Any other value (or absent) serves from cache (cache.memo key 'port:<wallet>', 30000ms TTL)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"totalValueUSD\":12834.55,\"tokenCount\":7,\"tokens\":[{\"mint\":\"So11111111111111111111111111111111111111112\",\"symbol\":\"SOL\",\"name\":\"Solana\",\"image\":\"https://.../logo.png\",\"balance\":42.137,\"usdValue\":6320.55,\"price\":150.0,\"priceChange24h\":-2.3,\"allocation\":49.2},{\"mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"symbol\":\"USDC\",\"name\":\"USD Coin\",\"image\":null,\"balance\":3100.0,\"usdValue\":3100.0,\"price\":1.0,\"priceChange24h\":0,\"allocation\":24.1}]}",
     "responseNotes": "Top-level keys: success, wallet, then the spread of the shaped portfolio object: totalValueUSD (number), tokenCount (number), tokens (array). Each token has mint, symbol, name, image, balance, usdValue, price, priceChange24h, allocation. On the Stryke's node layer fallback path, priceChange24h and allocation are 0 and totalValueUSD is summed from per-token usdValue. SOL is injected as the first token on the fallback path when nativeBalance.lamports > 0.",
     "errorCodes": [
      "400 invalid wallet (fails base58 regex)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream provider error (err.message — e.g. 'no portfolio provider configured', the market-data layer/Stryke's node layer failure)"
     ],
     "notes": "Cached 30s by default via cache.memo('port:<wallet>'). shapePortfolio maps the market-data layer data.assets[] (asset.contracts solana address, symbol, name, logo, token_balance, estimated_balance, price, price_change_24h, allocation) and uses data.total_wallet_balance / data.balances_length. Stryke's node layer fallback only includes FungibleToken/FungibleAsset interfaces with balance > 0. on the fallback pricing path, priceChange24h and allocation are 0, and native SOL placement differs from the primary path — do not rely on token ordering.",
     "id": "get-api-v2-wallets-wallet-portfolio"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/nfts",
     "name": "Wallet NFTs",
     "summary": "Non-fungible holdings for a wallet — standard, compressed, and MPL-Core assets — resolved via the metadata/asset index. Fungible token balances are filtered out (use /portfolio for those). Paginated. Cached 60s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Owner wallet address (base58). 400 'invalid wallet address' if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "page",
       "type": "integer",
       "required": false,
       "default": "1",
       "description": "DAS page number (1-based)."
      },
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Assets per page. Clamped to 1–100."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"GDfnEsia2WLAW5t8yx2X5j2mkfA74i5kwGdDuZHt7XmG\",\"page\":1,\"limit\":4,\"count\":4,\"total\":4,\"nfts\":[{\"mint\":\"HJR4SEEQBjGDZavq44WqgL7X7YUsDTZiW154ptgjAkTe\",\"name\":\"Magic #727\",\"symbol\":\"DWNO\",\"image\":\"https://bafybeigbcal7fql27dbjaseih533b6tvprmhasacr65z4xahjwsv2baozi.ipfs.nftstorage.link/0.gif\",\"collection\":null,\"compressed\":true,\"interface\":\"V1_NFT\"},{\"mint\":\"H471xMeNTxFmBh6PoDNkUuL3VmKctXqj67w414CYcShS\",\"name\":\"Third round Drop\",\"symbol\":\"3TRD\",\"image\":\"https://img.hi-hi.vip/json/img/555jup.png\",\"collection\":\"7MgADPWSAgd7HjkzLZGjby3EbhZZQUzdptXzTsotEV7\",\"compressed\":true,\"interface\":\"V1_NFT\"}]}",
     "responseNotes": "nfts[] each: { mint, name, symbol, image (resolved metadata image / CDN URI), collection (the collection grouping address, or null if ungrouped), compressed (true for compressed NFTs), interface (asset standard — V1_NFT, ProgrammableNFT, MplCoreAsset, etc.) }. count is the NFTs on this page after filtering out fungibles; total is the provider's total asset count for the page query (fungibles included). Airdrop / spam NFTs are not filtered — they are real on-chain holdings.",
     "errorCodes": [
      "400 invalid wallet address",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 asset provider not configured",
      "502 upstream asset-index error"
     ],
     "notes": "Cache key nfts:<wallet>:<page>:<limit>, TTL 60s. Filters out fungible interfaces and any asset whose token decimals are greater than 0. `total` is the provider's page total INCLUDING fungibles that were filtered out, so total >= count and is NOT the NFT count; total can be null.",
     "id": "get-api-v2-wallets-wallet-nfts"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/history",
     "name": "Portfolio balance history",
     "summary": "Returns a time series of the wallet's total balance (USD value) over time from the market-data layer's wallet/history endpoint, normalized to {t,v} points.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "period",
       "type": "string",
       "required": false,
       "default": "30d",
       "description": "One of 1h, 1d, 7d, 30d, 90d, 365d. Any other value falls back to 30d."
      },
      {
       "name": "from",
       "type": "number (unix)",
       "required": false,
       "default": "null",
       "description": "Start timestamp; passed through to the market-data layer as 'from'. Parsed via Number()."
      },
      {
       "name": "to",
       "type": "number (unix)",
       "required": false,
       "default": "null",
       "description": "End timestamp; passed through to the market-data layer as 'to'. Parsed via Number()."
      },
      {
       "name": "asset",
       "type": "string",
       "required": false,
       "default": "null",
       "description": "Restrict the history to a single asset/token; passed through to the market-data layer as 'asset'."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"period\":\"30d\",\"from\":null,\"to\":null,\"series\":[{\"t\":1717200000000,\"v\":11250.42},{\"t\":1717286400000,\"v\":11980.10},{\"t\":1717372800000,\"v\":12834.55}]}",
     "responseNotes": "Top-level keys: success, wallet, period, from, to, series. series is an array of {t, v} points mapped from the market-data layer data.balance_history (supports both [timestamp,value] tuple and {timestamp/t, value/v} object forms); points with falsy t or non-finite v are filtered out. Cached 60s via cache.memo key 'hist:<wallet>:<period>:<from>:<to>:<asset>'.",
     "errorCodes": [
      "400 invalid wallet",
      "503 history provider not configured (the market-data layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream the market-data layer error"
     ],
     "notes": "Requires the market-data layer configured or returns 503 with success:false. the market-data layer timeout for history is 25s. requires the history provider configured or returns 503 before any work; series points with a missing timestamp or non-finite value are silently dropped (gaps are not surfaced).",
     "id": "get-api-v2-wallets-wallet-history"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/trades",
     "name": "Wallet trades (swap history)",
     "summary": "Returns the wallet's the market-data layer-derived swap/trade history, passed through largely verbatim from the market-data layer's wallet/trades endpoint.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "100",
       "description": "Number of trades. Clamped to 1..500 via Math.max(1, Math.min(500, Number(limit) || 100))."
      },
      {
       "name": "from",
       "type": "number (unix)",
       "required": false,
       "default": "null",
       "description": "Start timestamp; passed to the market-data layer as 'from'."
      },
      {
       "name": "to",
       "type": "number (unix)",
       "required": false,
       "default": "null",
       "description": "End timestamp; passed to the market-data layer as 'to'."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"count\":2,\"trades\":[{\"date\":1717200000,\"type\":\"buy\",\"token_amount\":1500000,\"token_amount_usd\":420.5,\"hash\":\"5x...sig\"},{\"date\":1717286400,\"type\":\"sell\",\"token_amount\":1500000,\"token_amount_usd\":510.2,\"hash\":\"9z...sig\"}]}",
     "responseNotes": "Top-level keys: success, wallet, count, trades. count is trades.length. trades is the market-data layer's data.trades array passed through unchanged (field names are the market-data layer's own, not re-shaped by this handler). Cached 60s via cache.memo key 'trades:<wallet>:<limit>:<from>:<to>'.",
     "errorCodes": [
      "400 invalid wallet",
      "503 trades provider not configured (the market-data layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream the market-data layer error (known to occasionally 503 upstream)"
     ],
     "notes": "Per CLAUDE.md the upstream the market-data layer trades provider can flake to 503; consumers should degrade gracefully. Requires the market-data layer configured. RAW PASSTHROUGH: the trades array is returned verbatim from the upstream market-data provider (provider field names, not re-shaped); count = trades.length.",
     "id": "get-api-v2-wallets-wallet-trades"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/activity",
     "name": "Wallet activity feed",
     "summary": "Returns recent on-chain wallet events (swaps, transfers, liquidations, etc.) from the market-data layer's v2 wallet/activity endpoint with spam filtering and unlisted assets enabled.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Number of activity items. Clamped to 1..200 via Math.max(1, Math.min(200, Number(limit) || 50))."
      },
      {
       "name": "order",
       "type": "string",
       "required": false,
       "default": "desc",
       "description": "Sort order. 'asc' or 'desc' — anything other than 'asc' becomes 'desc'."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"count\":2,\"activity\":[{\"hash\":\"5x...sig\",\"timestamp\":1717286400,\"type\":\"swap\",\"asset\":{\"symbol\":\"BONK\"},\"amount\":1500000,\"amount_usd\":510.2},{\"hash\":\"3a...sig\",\"timestamp\":1717200000,\"type\":\"transfer\",\"amount_usd\":12.0}]}",
     "responseNotes": "Top-level keys: success, wallet, count, activity. count is the length of the market-data layer's data array. activity is the market-data layer's data array passed through unchanged (the market-data layer field names). the market-data layer call sets filterSpam=true and unlistedAssets=true. Cached 30s via cache.memo key 'act:<wallet>:<limit>:<order>'.",
     "errorCodes": [
      "400 invalid wallet",
      "503 activity provider not configured (the market-data layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream the market-data layer error"
     ],
     "notes": "the market-data layer activity timeout is 30s. Requires the market-data layer configured. UPSTREAM-FILTERED: spam transfers are filtered out upstream, so this is NOT a complete event feed; the activity array is returned verbatim (provider field names).",
     "id": "get-api-v2-wallets-wallet-activity"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/transactions",
     "name": "Decoded transactions (Stryke's node layer enhanced)",
     "summary": "Returns parsed, human-readable transactions for the wallet via Stryke's node layer's Enhanced Transactions REST API — each with a type, source, description, tokenTransfers and nativeTransfers. Supports keyset pagination and type/source filters.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Number of transactions. Clamped to 1..100 via Math.max(1, Math.min(100, Number(limit) || 50))."
      },
      {
       "name": "before",
       "type": "string (signature)",
       "required": false,
       "default": "(none)",
       "description": "Paginate: return txs before this signature. Only applied if it matches the signature regex /^[a-zA-Z0-9]{43,128}$/."
      },
      {
       "name": "until",
       "type": "string (signature)",
       "required": false,
       "default": "(none)",
       "description": "Paginate: return txs until this signature. Only applied if it passes signature validation."
      },
      {
       "name": "type",
       "type": "string",
       "required": false,
       "default": "(none)",
       "description": "Filter by Stryke's node layer transaction type (e.g. SWAP, TRANSFER, NFT_SALE). Truncated to 40 chars and passed to Stryke's node layer."
      },
      {
       "name": "source",
       "type": "string",
       "required": false,
       "default": "(none)",
       "description": "Filter by Stryke's node layer source (e.g. JUPITER, RAYDIUM, MAGIC_EDEN). Truncated to 40 chars and passed to Stryke's node layer."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"count\":1,\"transactions\":[{\"signature\":\"5x...sig\",\"timestamp\":1717286400,\"type\":\"SWAP\",\"source\":\"JUPITER\",\"fee\":15000,\"feePayer\":\"6Yg9...M3kP\",\"description\":\"Swapped 1.5 SOL for 1500000 BONK\",\"tokenTransfers\":[{\"fromUserAccount\":\"...\",\"toUserAccount\":\"6Yg9...M3kP\",\"mint\":\"DezX...BONK\",\"tokenAmount\":1500000}],\"nativeTransfers\":[{\"fromUserAccount\":\"6Yg9...M3kP\",\"toUserAccount\":\"...\",\"amount\":1500000000}],\"instructions\":[{\"programId\":\"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4\"}],\"transactionError\":null}]}",
     "responseNotes": "Top-level keys: success, wallet, count, transactions. transactions is the raw Stryke's node layer Enhanced Transactions array (this handler does NOT re-shape it). Each item carries the Stryke's node layer schema: signature, timestamp, type, source, fee, feePayer, slot, description, tokenTransfers[], nativeTransfers[], instructions[], events, transactionError. count is transactions.length.",
     "errorCodes": [
      "400 invalid wallet",
      "503 transactions provider not configured (Stryke's node layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream Stryke's node layer enhanced-tx error (non-200 status)"
     ],
     "notes": "Not cached at the route layer (live Stryke's node layer call each request). Stryke's node layer caps limit at 100 internally. Requires Stryke's node layer configured. FAILED TXS: the enhanced-transactions feed returns successfully-parsed transactions only — reverted/failed transactions are NOT included in this array. To count or inspect failed transactions, use getSignaturesForAddress (inspect the `err` field) via the RPC endpoint.",
     "id": "get-api-v2-wallets-wallet-transactions"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/funding",
     "name": "Funding source (genesis)",
     "summary": "Traces a wallet's first-ever transaction to surface who funded it and with how much SOL. Uses an archival genesis-forward (oldest-first) lookup when available, else falls back to the oldest signature in the recent window. Genesis is immutable, so the result is cached 1h.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Wallet address to trace (base58). 400 'invalid wallet address' if invalid."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"GDfnEsia2WLAW5t8yx2X5j2mkfA74i5kwGdDuZHt7XmG\",\"funded\":true,\"funder\":\"crwnQamrVin5xNjhCmiv93U8GDgwpxB9GQbqmx8UWFQ\",\"amountSol\":0.0014616,\"signature\":\"3cUGwFapDzXukYGirzvouFutqRh2FVxMKvQQQRswDuPprsxE2No6xnTgB1y2GkkTaN1jfdG2Zus4HcDtHVQaNqoV\",\"blockTime\":1674570378,\"slot\":174160573}",
     "responseNotes": "funded (bool). When funded:true → funder (the account that sent the most SOL in the genesis transaction), amountSol (SOL the wallet received in that tx; falls back to the funder's net debit), signature (the genesis transaction), blockTime (unix seconds), slot. funded:false (no other fields) when the wallet has no discoverable transaction history. funder is a heuristic — the non-wallet account with the largest balance decrease — so for genesis txs with multiple senders it returns the dominant source.",
     "errorCodes": [
      "400 invalid wallet address",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 transaction provider not configured",
      "502 upstream RPC error"
     ],
     "notes": "Cache key funding:<wallet>, TTL 1h. Primary path is a single archival oldest-first transaction lookup (limit 1); the fallback walks the newest-1000 signature window and reads the oldest transaction. `funder` is a heuristic (the largest-balance-decrease account in the genesis tx) and can misattribute multi-sender/program-mediated funding; `amountSol` may include the network fee when the wallet's net receipt isn't positive. Returns funded:false when no history is discoverable.",
     "id": "get-api-v2-wallets-wallet-funding"
    },
    {
     "method": "POST",
     "path": "/api/v2/wallets/decode-tx",
     "name": "Bulk decode transactions",
     "summary": "Bulk-decodes arbitrary transaction signatures into Stryke's node layer's parsed/enhanced format. Send a signatures array; up to 100 valid signatures are decoded via Stryke's node layer's /v0/transactions parser.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "signatures",
       "type": "string[]",
       "required": true,
       "description": "Array of base58 transaction signatures. Filtered by the signature regex /^[a-zA-Z0-9]{43,128}$/ and capped at the first 100 valid entries. If zero valid signatures remain, returns 400 'signatures[] required (max 100 valid sigs)'."
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"transactions\":[{\"signature\":\"5x...sig\",\"timestamp\":1717286400,\"type\":\"SWAP\",\"source\":\"JUPITER\",\"fee\":15000,\"feePayer\":\"6Yg9...M3kP\",\"description\":\"Swapped 1.5 SOL for 1500000 BONK\",\"tokenTransfers\":[{\"mint\":\"DezX...BONK\",\"tokenAmount\":1500000}],\"nativeTransfers\":[{\"amount\":1500000000}],\"transactionError\":null}]}",
     "responseNotes": "Top-level keys: success, count, transactions (no wallet key — this is a signature-keyed bulk decode). transactions is the raw Stryke's node layer parseTransactions array (same enhanced schema as the /transactions endpoint). count is transactions.length.",
     "errorCodes": [
      "400 signatures[] required (max 100 valid sigs) — body missing/empty or no valid signatures",
      "503 decode provider not configured (Stryke's node layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream Stryke's node layer parse-transactions error"
     ],
     "notes": "This is the only non-:wallet, body-driven route in the file and the only POST. Not cached. Stryke's node layer parseTransactions also re-filters and slices to 100. Path is literally /api/v2/wallets/decode-tx. PARTIAL RESULTS: signatures that cannot be decoded are silently dropped — count may be less than the number submitted, and results are NOT positionally aligned to the input order. Match each result by its own `signature`, not by array index.",
     "id": "post-api-v2-wallets-decode-tx"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/profile",
     "name": "Deep wallet profile",
     "summary": "The heavy aggregation endpoint. Walks up to 5 Stryke's node layer enhanced-tx pages plus 1 the market-data layer portfolio and computes a structured WalletProfile: identity/classification, biography, activity rhythm, per-token cohorts with rough realised PnL, behavioral fingerprint (sniper/paperhand/diamond/gambler/night-owl/discipline), velocity, sizing, risk, DEX breakdown, counterparty graph, holdings, trader tags and dozens of derived sections.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "depth",
       "type": "string",
       "required": false,
       "default": "deep",
       "description": "'fast' walks 2 Stryke's node layer pages (~200 txs); anything else (default) is 'deep' = 5 pages (~500 txs)."
      },
      {
       "name": "since",
       "type": "number (unix seconds)",
       "required": false,
       "default": "null",
       "description": "Filter txs to timestamp >= since. Snapped to the nearest day (floor(since/86400)*86400) so cache keys converge. Used by /wrapped to scope to a one-year window. Non-finite or <=0 is ignored (null)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"depth\":\"deep\",\"scanned\":487,\"solPriceUsd\":150.2,\"identity\":{\"kind\":\"active_trader\",\"label\":\"Active trader\"},\"biography\":\"...\",\"activity\":{\"ageDays\":214,\"oldestTs\":1698800000,\"newestTs\":1717286400,\"hourBins\":[3,1,0],\"dowBins\":[12,40,33,29,31,25,18],\"hourDowMatrix\":[0],\"longestStreakDays\":9,\"busiestDay\":{\"date\":\"2026-03-14\",\"count\":41},\"nftActivity\":2,\"selfTransfers\":4,\"topPrograms\":[{\"id\":\"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4\",\"count\":120,\"name\":\"Jupiter v6\"}]},\"risk\":{\"totalTx\":487,\"failedCount\":18,\"failureRate\":3.7,\"avgGasPerSwapLamports\":42000,\"totalFeesLamports\":3100000,\"totalJitoTipsLamports\":800000,\"jitoTipCount\":12,\"biggestLossLamports\":2400000000,\"biggestLossSig\":\"5x...\",\"biggestWinLamports\":1900000000,\"biggestWinSig\":\"9z...\"},\"fingerprint\":{\"sniper\":34,\"paperhand\":61,\"diamond\":22,\"gambler\":48,\"nightOwl\":18,\"discipline\":96,\"drift\":{\"gambler\":-5,\"nightOwl\":3,\"sample\":120},\"avgHoldHours\":7.4,\"cohortCount\":63,\"boughtCount\":41,\"flipSample\":22},\"velocity\":{\"txPerDay\":2.3,\"tokensPerDay\":0.9,\"intervals\":{\"count\":486,\"avgSec\":37000,\"medianSec\":900,\"cv\":2.4,\"histogram\":[]},\"isBotlike\":false,\"peakTps\":6,\"burstMinutes\":14,\"activeMinutes\":300,\"burstPct\":4.7,\"momentumLabel\":\"steady\",\"momentumRatio\":1.0},\"sizing\":{\"buyCount\":40,\"avgBuySol\":0.85,\"medianBuySol\":0.5,\"maxBuySol\":12.0,\"p90BuySol\":3.1,\"largestBuyMint\":\"DezX...\",\"largestBuySymbol\":\"BONK\",\"avgBuyUsd\":128,\"sizePnlCorr\":0.12,\"sizePnlSample\":18,\"histogram\":[],\"disciplineScore\":71,\"disciplineCv\":0.96},\"failures\":{\"total\":18,\"byProgram\":[],\"byHour\":[],\"topErrors\":[]},\"pnl\":{\"realisedSol\":4.2,\"realisedUsd\":631,\"unrealisedUsd\":-220,\"netUsd\":411,\"wins\":11,\"losses\":7,\"breakeven\":3,\"closed\":21,\"profitFactor\":1.6},\"concentration\":{},\"mevAwareness\":{\"score\":18,\"tippedSwaps\":12,\"swapCount\":214},\"oldestPosition\":{\"mint\":\"...\",\"symbol\":\"WIF\"},\"biggestLesson\":{\"mint\":\"...\",\"symbol\":\"...\"},\"bestTrade\":{\"mint\":\"...\",\"symbol\":\"...\"},\"dexBreakdown\":{\"breakdown\":[{\"category\":\"jupiter\",\"count\":120,\"pct\":56}]},\"dexPnl\":{},\"compounding\":{\"score\":40,\"sample\":12},\"selfShuffler\":{\"score\":2,\"selfTransfers\":4,\"totalSends\":190,\"topDestinations\":[]},\"recoveryRate\":{\"score\":33,\"sample\":6,\"recoveredMints\":[]},\"overview\":{\"swapCount\":214,\"txCount\":487,\"swapVolumeSol\":182.4,\"swapVolumeUsd\":27396,\"feesSol\":0.0031,\"prioritySol\":0.0006,\"jitoSol\":0.0008,\"activeDays\":140,\"longestStreakDays\":9,\"currentStreakDays\":0,\"firstSwapTs\":1698800000,\"lastSwapTs\":1717286400,\"monthlyFees\":[],\"monthlyVolume\":[]},\"hourlyPerformance\":[],\"hourlyDowMatrix\":[],\"holdDistribution\":[],\"holdSummary\":{},\"dustAttackIndex\":{\"count\":9,\"total\":63,\"pct\":14},\"stakingOpportunity\":{\"idleSol\":42.1,\"stakeableSol\":42.05,\"yearlySol\":2.94,\"yearlyUsd\":441.9,\"apyPct\":7.0},\"closeableAccounts\":{\"total\":58,\"empty\":31,\"nonempty\":27,\"recoverableSol\":0.063,\"recoverableUsd\":9.46},\"monthlyPnl\":[],\"sharpe\":{},\"lossAversion\":{},\"streaks\":{\"longest\":9,\"currentlyActive\":0,\"longestHourRun\":5},\"failureBreakdown\":{},\"tokenGraveyard\":{},\"panicSells\":{},\"hodlBenchmark\":{},\"biggestDays\":[],\"traderTags\":[{\"key\":\"pump-degen\",\"label\":\"Pump.fun degen\",\"tone\":\"orange\"}],\"cohorts\":{\"total\":63,\"stillHolding\":15,\"bought\":41,\"top\":[{\"mint\":\"DezX...\",\"symbol\":\"BONK\",\"name\":\"Bonk\",\"image\":\"...\",\"priceUsd\":0.00002,\"buys\":4,\"sells\":3,\"receives\":1,\"sends\":0,\"wasBought\":true,\"realisedSol\":0.8,\"realisedUsd\":120,\"stillHolds\":true,\"firstBuyTs\":1700000000,\"lastActivityTs\":1717200000,\"avgHoldHours\":36.2}]},\"trades\":{\"wins\":[],\"losses\":[]},\"counterparties\":[{\"addr\":\"9WzD...AWWM\",\"inLamports\":0,\"outLamports\":500000000,\"count\":3,\"swapTouches\":0,\"totalLamports\":500000000,\"netLamports\":-500000000,\"flavor\":\"transfer\",\"label\":\"Coinbase\",\"kind\":\"cex\"}],\"holdings\":{\"totalUsd\":12834.55,\"tokenCount\":7,\"dustBags\":3,\"topBags\":[{\"symbol\":\"BONK\",\"name\":\"Bonk\",\"mint\":\"DezX...\",\"balance\":1500000,\"usd\":420.5,\"price\":0.00002,\"change24h\":-3.1,\"allocation\":3.2}],\"worst24h\":{\"symbol\":\"BONK\",\"change24h\":-3.1,\"delta\":-13.0}}}",
     "responseNotes": "Response is {success:true,...assemble(...)}. the assembler returns ~50 top-level keys: wallet, depth, scanned (txs analyzed after the since-filter), solPriceUsd, identity {kind,label}, biography (string), activity (with topPrograms now carrying a resolved name field), risk, fingerprint, velocity, sizing (sizingRich with largestBuySymbol/avgBuyUsd/maxBuyUsd/sizePnlCorr/sizePnlSample), failures, pnl, concentration, mevAwareness, oldestPosition, biggestLesson, bestTrade, dexBreakdown, dexPnl, compounding, selfShuffler, recoveryRate, overview, hourlyPerformance, hourlyDowMatrix, holdDistribution, holdSummary, dustAttackIndex {count,total,pct}, stakingOpportunity {idleSol,stakeableSol,yearlySol,yearlyUsd,apyPct} (null when idle <= 0.05 reserve), closeableAccounts {total,empty,nonempty,recoverableSol,recoverableUsd} (null if Stryke's node layer unavailable), monthlyPnl, sharpe, lossAversion, streaks, failureBreakdown, tokenGraveyard, panicSells, hodlBenchmark, biggestDays, traderTags (array of {key,label,tone}, capped 8), cohorts {total,stillHolding,bought,top[]} (top is up to 30 enriched per-token cohorts), trades {wins[],losses[]}, counterparties (top 10, labeled with label/kind), holdings (the market-data layer portfolio: totalUsd, tokenCount, dustBags, topBags[], worst24h) or null. The example trims long arrays for brevity.",
     "errorCodes": [
      "400 invalid wallet",
      "503 profile provider not configured (Stryke's node layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream error during assembly (Stryke's node layer/the market-data layer failure surfaced as err.message)"
     ],
     "notes": "Cached 5 min server-side via cache.memo('profile:<depth>:<wallet>' or '...:since=<snapped>', 300000ms). Sets response header Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=600. Cost on cold cache: up to 5 Stryke's node layer enhanced-tx pages (2 for depth=fast) + 1 the market-data layer portfolio + 1 DAS getAssetBatch + getTokenAccountsByOwner. The same profile cache is reused by /wrapped and /roast. NULLABLE SUB-OBJECTS: stakingOpportunity, closeableAccounts, holdings (and other sub-objects) can be null when their inputs are unavailable — null-check each. `depth`: only 'fast' is special-cased; any other value (including typos) is treated as 'deep' = maximum upstream cost.",
     "id": "get-api-v2-wallets-wallet-profile"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/wrapped",
     "name": "Wallet wrapped (year-in-review)",
     "summary": "The interpretive 'wrapped' layer built on top of profile.the assembler. Maps the deep profile onto a WrappedStats object, a WalletDiagnostics object, and a wallet classification — the gentle year-in-review (no roast verdict). Reuses the same profile cache so a /profile -> /wrapped burst hits cache.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "depth",
       "type": "string",
       "required": false,
       "default": "deep",
       "description": "'fast' (2 Stryke's node layer pages) or 'deep' (5 pages, default). Same semantics as /profile."
      },
      {
       "name": "since",
       "type": "number (unix seconds)",
       "required": false,
       "default": "null",
       "description": "One-year-window scoping. Snapped to nearest day (floor(since/86400)*86400). Non-finite/<=0 ignored. Used to build both the profile and wrapped cache keys."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"depth\":\"deep\",\"stats\":{\"address\":\"6Yg9...M3kP\",\"txCount\":487,\"swapCount\":214,\"transferCount\":0,\"failedCount\":18,\"totalFeesLamports\":3100000,\"totalPriorityFeesLamports\":600000,\"totalJitoTipsLamports\":800000,\"totalSolMovedLamports\":182400000000,\"biggestTx\":null,\"biggestLossTx\":null,\"oldestTx\":null,\"newestTx\":null,\"uniquePrograms\":7,\"uniqueTokens\":63,\"topPrograms\":[{\"id\":\"JUP6Lkb...\",\"count\":120}],\"topTokens\":[{\"mint\":\"DezX...\",\"count\":8}],\"busiestDay\":{\"date\":\"2026-03-14\",\"count\":41},\"jitoTipCount\":12,\"dustAttacks\":0,\"oneLiner\":\"214 swaps. Burned ◎0.003 in fees. Therapy?\"},\"diagnostics\":{\"botFeesLamports\":0,\"botFeeCount\":0,\"topBots\":[],\"totalTokenAccounts\":58,\"closeableAccounts\":31,\"recoverableLamports\":63000000,\"hasStake\":false,\"stakedLamports\":0,\"solBalanceLamports\":42100000000,\"rapidRoundTrips\":0,\"duplicateBuys\":[],\"uniqueShitcoins\":41,\"biggestLossSig\":\"5x...\",\"biggestLossLamports\":2400000000,\"pumpFunBuys\":62,\"walletAgeDays\":214,\"walletAgeIsLowerBound\":false,\"totalTxAllTime\":0,\"failedTxAllTime\":0,\"busiestDayAllTime\":{\"date\":\"2026-03-14\",\"count\":41},\"hourHistogram\":[3,1,0],\"lateNightSwapPct\":0.18,\"avgGasPerSwapLamports\":42000,\"nftActivityCount\":2,\"selfTransferCount\":4,\"topProgramId\":\"JUP6Lkb...\",\"topProgramCount\":120,\"topProgramPct\":0.56,\"longestStreakDays\":9,\"totalNotionalLamports\":182400000000,\"portfolio\":{\"totalUsd\":12834.55,\"assets\":[]},\"topBagSymbol\":\"BONK\",\"topBagUsd\":420.5,\"dustBagsCount\":3,\"worstPerformerSymbol\":\"BONK\",\"worstPerformerChange24h\":-3.1,\"chronicBagsCount\":0},\"classification\":{\"kind\":\"active_trader\",\"label\":\"Active trader\",\"blurb\":\"214 swaps across 63 tokens. Fully online.\",\"confidence\":0.8}}",
     "responseNotes": "Response is {success:true, wallet, depth, stats, diagnostics, classification}. stats is Stryke's WrappedStats (address, txCount, swapCount, transferCount[always 0 GAP], failedCount, totalFeesLamports, totalPriorityFeesLamports, totalJitoTipsLamports, totalSolMovedLamports, biggestTx/biggestLossTx/oldestTx/newestTx [null GAPs], uniquePrograms, uniqueTokens, topPrograms[], topTokens[], busiestDay, jitoTipCount, dustAttacks[0 GAP], oneLiner). diagnostics is WalletDiagnostics (bot-fee fields are 0/[] GAPs, plus closeableAccounts, recoverableLamports, solBalanceLamports, pumpFunBuys[approx from dexBreakdown], walletAgeDays, lateNightSwapPct, topProgramPct, portfolio, topBag*, worstPerformer*, etc). classification is {kind,label,blurb,confidence}. No 'roast' key (see /roast).",
     "errorCodes": [
      "400 invalid wallet",
      "503 profile provider not configured (Stryke's node layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream error during assembly/mapping"
     ],
     "notes": "Cached 5 min via cache.memo('wrapped:<depth>:<wallet>[:since=...]'), and internally memoizes the underlying 'profile:<depth>:<wallet>[:since=...]' so it shares the /profile cache (no extra tx walk on a warm profile). Same Cache-Control header as /profile. Built by wrapped.fromProfile() -> {stats,diagnostics,classification} (roast omitted). Several fields are documented GAPs that degrade to 0/[]/false where Stryke profile lacks the Stryke-specific input (bot fees, duplicate buys, dust attacks, all-time totals). DEGRADED FIELDS: several stats/diagnostics fields are best-effort and fall back to 0/[]/false when the underlying inputs are unavailable — do not treat these as genuine zeros.",
     "id": "get-api-v2-wallets-wallet-wrapped"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/roast",
     "name": "Wallet roast (wrapped + verdict)",
     "summary": "Everything /wrapped returns PLUS a roast verdict object (headline, burns, receipts, actions, lScore, tweetText) — the full output Stryke's /roast page assembles. Same cache/cost profile as /wrapped.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "depth",
       "type": "string",
       "required": false,
       "default": "deep",
       "description": "'fast' (2 Stryke's node layer pages) or 'deep' (5 pages, default)."
      },
      {
       "name": "since",
       "type": "number (unix seconds)",
       "required": false,
       "default": "null",
       "description": "One-year-window scoping. Snapped to nearest day. Non-finite/<=0 ignored. Builds both profile and roast cache keys."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"6Yg9...M3kP\",\"depth\":\"deep\",\"stats\":{\"address\":\"6Yg9...M3kP\",\"txCount\":487,\"swapCount\":214,\"failedCount\":18,\"uniqueTokens\":63,\"uniquePrograms\":7,\"oneLiner\":\"214 swaps. Burned ◎0.003 in fees. Therapy?\"},\"diagnostics\":{\"botFeesLamports\":0,\"closeableAccounts\":31,\"recoverableLamports\":63000000,\"solBalanceLamports\":42100000000,\"hasStake\":false,\"pumpFunBuys\":62,\"lateNightSwapPct\":0.18,\"biggestLossLamports\":2400000000,\"biggestLossSig\":\"5x...\",\"walletAgeDays\":214,\"topProgramPct\":0.56,\"dustBagsCount\":3},\"classification\":{\"kind\":\"active_trader\",\"label\":\"Active trader\",\"blurb\":\"214 swaps across 63 tokens. Fully online.\",\"confidence\":0.8},\"roast\":{\"headline\":\"62 pump.fun buys. You didn't find gems — you became exit liquidity for the dev.\",\"burns\":[\"62 pump.fun buys. You didn't find gems — you found exit liquidity for the dev.\",\"31 empty token accounts you forgot about — ◎0.063 (~$9.46) of rent locked in zombies.\",\"◎42.10 idle, zero staked. ~$442/year of validator rewards you're leaving on the table.\"],\"receipts\":[{\"label\":\"Total TXs\",\"value\":\"487\",\"hint\":\"...\",\"scope\":\"recent\"},{\"label\":\"Swaps\",\"value\":\"214\",\"scope\":\"recent\"},{\"label\":\"Fees burned\",\"value\":\"◎0.003\",\"scope\":\"recent\"}],\"actions\":[{\"kind\":\"close\",\"label\":\"Reclaim ◎0.063 now\",\"href\":\"/close-accounts\",\"reason\":\"31 empty token accounts on your wallet. One signature, gas-only, the SOL flows back to you.\",\"potentialSavings\":\"◎0.063 (~$9.46)\"},{\"kind\":\"solfolio\",\"label\":\"Stake on Solfolio — ~$442/yr\",\"href\":\"https://solfolio.gg/?utm_source=Stryke&utm_medium=referral&utm_campaign=roast-no-stake\",\"reason\":\"Your ◎42.10 earns nothing...\",\"potentialSavings\":\"~$442/year at ~7% APY\"},{\"kind\":\"wrapped\",\"label\":\"See the gentle wrapped\",\"href\":\"/wrapped/6Yg9...M3kP\",\"reason\":\"Same data, no insults.\"}],\"lScore\":47,\"tweetText\":\"L-Score 47/100\\n\\n62 pump.fun buys. You didn't find gems...\\n\\nroasted by @stryke_gg →\"}}",
     "responseNotes": "Response is {success:true, wallet, depth, stats, diagnostics, classification, roast} — identical to /wrapped plus the roast key. roast is roastFromStats() output: headline (string, the highest-scoring verdict from pickVerdict), burns (string[]), receipts (array of {label,value,hint?,scope?}), actions (array of {kind,label,href,reason,potentialSavings?} — kinds: stryke/close/solfolio/wrapped, with affiliate UTM-wrapped hrefs), lScore (integer 1..99), tweetText (string). For a zero-tx wallet roast returns a fixed pristine-wallet verdict.",
     "errorCodes": [
      "400 invalid wallet",
      "503 profile provider not configured (Stryke's node layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream error during assembly/mapping"
     ],
     "notes": "Cached 5 min via cache.memo('roast:<depth>:<wallet>[:since=...]'), internally reusing the shared 'profile:<depth>:<wallet>[:since=...]' cache. Same Cache-Control header as /profile and /wrapped. Built by wrapped.fromProfile() destructured to {stats,diagnostics,classification,roast}. solPriceUsd comes from the profile; falls back to 150. Roast text/verdict selection is deterministic per wallet (djb2 hash of address seeds verdict template choice). DETERMINISTIC: the roast verdict text is seeded by the wallet address — the same wallet always returns the same roast (not randomized). Zero-activity wallets return a fixed 'pristine wallet' verdict rather than an error.",
     "id": "get-api-v2-wallets-wallet-roast"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/transfers",
     "name": "Wallet transfers / fund-flow",
     "summary": "Raw token + native-SOL transfer edges decoded from Stryke's node layer enhanced transactions, for fund-flow tracing. Filterable by mint, counterparty, and direction. With ?synthetic=1, instead returns SYNTHESIZED swap trade rows (sanitized) in the same shape /:wallet/trades emits — for wallets a market provider misses.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58)."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "description": "Number of enhanced txs to scan (1-100, default 100).",
       "required": false
      },
      {
       "name": "before",
       "type": "string",
       "description": "Signature to paginate before (older txs).",
       "required": false
      },
      {
       "name": "mint",
       "type": "string",
       "description": "Only return transfer edges for this mint (transfer mode only).",
       "required": false
      },
      {
       "name": "counterparty",
       "type": "string",
       "description": "Only edges where this address is the sender or receiver (transfer mode only).",
       "required": false
      },
      {
       "name": "direction",
       "type": "string",
       "description": "Filter edges by direction: 'in' or 'out' (transfer mode only).",
       "required": false
      },
      {
       "name": "synthetic",
       "type": "string",
       "description": "Set to '1' or 'true' to return synthesized swap trade rows instead of raw transfer edges.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"wallet\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"count\":2,\"transfers\":[{\"signature\":\"3xQ...\",\"ts\":1719446400,\"mint\":\"So11111111111111111111111111111111111111112\",\"amount\":1.25,\"from\":\"9aB...\",\"to\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"direction\":\"in\",\"kind\":\"sol\"},{\"signature\":\"4yR...\",\"ts\":1719446100,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"amount\":150000,\"from\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"to\":\"7pH...\",\"direction\":\"out\",\"kind\":\"token\"}]}",
     "responseNotes": "Default (transfer) mode: transfers[] of {signature, ts (epoch sec), mint, amount (UI units; SOL lamports/1e9), from, to, direction ('in' if wallet is recipient else 'out'), kind ('sol'|'token')}. Filters mint/counterparty/direction applied post-decode. With ?synthetic=1: returns {synthetic:true, count, trades[]} where each trade = {base_token, token0_address, token1_address, amount_base, amount_quote, side ('buy'|'sell'), date, hash} (only SWAP txs with both an input and output leg; QUOTE_MINTS = SOL/USDC/USDT treated as the quote side; sanitized). Enhanced-tx fetch cached 30s.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid wallet address"
      },
      {
       "code": 503,
       "when": "transfers provider (Stryke's node layer) not configured"
      },
      {
       "code": 502,
       "when": "Transfers unavailable (enhanced-tx upstream failed) — error \"transfers unavailable\""
      }
     ],
     "id": "wallets-transfers"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/stake",
     "name": "Wallet native stake accounts",
     "summary": "Native (SOL) stake accounts where the wallet is the withdrawer authority, read via Stake program getProgramAccounts (memcmp at offset 44). Returns per-account lamports/SOL, delegation state, vote account, active stake and rent-exempt flag, plus a total.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (withdrawer authority)."
      }
     ],
     "responseExample": "{\"success\":true,\"wallet\":\"GThUX1Atko4tqhN2NaiTazWSeFWMuiUiswQrAogwRGUS\",\"count\":1,\"totalStakedSol\":102.5,\"stakes\":[{\"stakeAccount\":\"StAkEjQ...\",\"lamports\":102500000000,\"sol\":102.5,\"state\":\"active\",\"voteAccount\":\"Vote111...\",\"activeStakeSol\":102.0,\"rentExempt\":true}]}",
     "responseNotes": "stakes[] of {stakeAccount, lamports, sol (lamports/1e9), state ('active'|'deactivating'|'inactive'), voteAccount (delegated validator or null), activeStakeSol (delegated stake/1e9, 0 if undelegated), rentExempt}. state is 'deactivating' when a non-a platform setting deactivationEpoch is set, 'active' when delegated, else 'inactive'. totalStakedSol sums stakes[].sol. Cached 120s.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid wallet address"
      },
      {
       "code": 503,
       "when": "stake provider (the node RPC) not configured"
      },
      {
       "code": 502,
       "when": "Stake lookup unavailable (getProgramAccounts failed) — error \"stake lookup unavailable\""
      }
     ],
     "id": "wallets-stake"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/tx-stats",
     "name": "Wallet transaction stats",
     "summary": "True lifetime-ish transaction and FAILED-tx counts from raw getSignaturesForAddress (the enhanced-tx feed omits failed txs and undercounts). Bounded pagination with a `capped` flag, plus age and failure-rate derived fields.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58)."
      }
     ],
     "queryParams": [
      {
       "name": "pages",
       "type": "integer",
       "description": "Max signature pages to scan, 1000 sigs each (1-50, default 10 = up to 10k sigs).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"wallet\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"totalTx\":8423,\"failedCount\":312,\"failureRatePct\":3.7,\"oldestTs\":1659312000,\"newestTs\":1719446400,\"ageDays\":695.5,\"capped\":false,\"pagesScanned\":9}",
     "responseNotes": "totalTx = signatures scanned; failedCount = those with a non-null err; failureRatePct = failed/total*100 rounded to 0.1. oldestTs/newestTs are epoch seconds across the scanned window; ageDays from oldestTs rounded to 0.1 (null if no block times). capped is true when the page cap was hit (totals are a lower bound — increase pages); pagesScanned is how many 1000-sig pages were actually read. Cached 300s.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid wallet address"
      },
      {
       "code": 503,
       "when": "tx provider (Stryke's node layer) not configured"
      },
      {
       "code": 502,
       "when": "tx-stats unavailable (getSignaturesForAddress failed) — error \"tx-stats unavailable\""
      }
     ],
     "id": "wallets-tx-stats"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/defi-positions",
     "name": "Wallet DeFi positions",
     "summary": "Cross-protocol open DeFi positions for a Solana wallet — Kamino/Drift/Orca+Raydium LP, lending, and staking — each with USD value, deposits, borrows, and rewards. Complements /portfolio (spot holdings) and /stake (native stake) so net worth reflects money in DeFi, not just tokens.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"GDfn...7XmG\",\"totalValueUSD\":12540.55,\"totalDepositedUSD\":11800,\"totalBorrowedUSD\":0,\"totalRewardsUSD\":42.11,\"protocolCount\":2,\"protocols\":[{\"name\":\"Kamino\",\"type\":\"lending\",\"positions\":[{\"token\":\"USDC\",\"valueUSD\":8000,\"apy\":6.4}]},{\"name\":\"Orca\",\"type\":\"lp\",\"positions\":[{\"pair\":\"SOL/USDC\",\"valueUSD\":4540.55,\"inRange\":true}]}]}",
     "id": "get-api-v2-wallets-wallet-defi-positions",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/pnl",
     "name": "Wallet PnL ledger",
     "summary": "Per-token profit-and-loss / cost-basis ledger for ANY Solana wallet (not just bot users): realized & unrealized PnL, average buy/sell price, buy/sell volume + counts, and fees paid. Use it to rank whales and vet copy-trade targets on hard numbers.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Positions to return. Clamped 1..200."
      },
      {
       "name": "onlyOpen",
       "type": "boolean",
       "required": false,
       "default": "false",
       "description": "When true, only currently-held positions."
      },
      {
       "name": "sortBy",
       "type": "string",
       "required": false,
       "description": "Sort field (e.g. totalPnlUSD, realizedPnlUSD)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"BHRE...G2AtX\",\"count\":3,\"summary\":{\"realizedPnlUSD\":-44.63,\"unrealizedPnlUSD\":0,\"totalPnlUSD\":-44.63,\"totalFeesPaidUSD\":3.59,\"winRate\":0,\"wins\":0,\"losses\":2,\"positionCount\":3},\"positions\":[{\"mint\":\"8DhB...pump\",\"symbol\":\"ROBBIN\",\"name\":\"robbin the hood\",\"logo\":\"https://...\",\"priceUSD\":0.00000864,\"balance\":0,\"valueUSD\":0,\"buys\":7,\"sells\":1,\"avgBuyPriceUSD\":0.0000469,\"avgSellPriceUSD\":0.0000409,\"realizedPnlUSD\":-9.94,\"unrealizedPnlUSD\":0,\"totalPnlUSD\":-9.94,\"totalFeesPaidUSD\":2.12,\"volumeBuyUSD\":77.28,\"volumeSellUSD\":67.35,\"firstTradeAt\":\"2026-07-08T18:09:50Z\",\"lastTradeAt\":\"2026-07-08T18:20:16Z\"}]}",
     "id": "get-api-v2-wallets-wallet-pnl",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/swaps",
     "name": "Wallet enriched swaps (MEV / fees)",
     "summary": "Per-swap fee decomposition for a Solana wallet: total / gas / platform / MEV fee split plus the routing platform (e.g. axiom, gmgn, trojan). A forensics layer over the plain activity feed — surface sandwich exposure and where a wallet routes its trades.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). Validated; invalid returns 400."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Swaps to return. Clamped 1..200."
      },
      {
       "name": "order",
       "type": "string",
       "required": false,
       "default": "desc",
       "description": "'asc' or 'desc'."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"BHRE...G2AtX\",\"count\":11,\"summary\":{\"swapCount\":11,\"totalFeesUSD\":3.8146,\"gasFeesUSD\":0.0901,\"platformFeesUSD\":2.866,\"mevFeesUSD\":0.8584,\"sandwichedCount\":0,\"platforms\":{\"Padre\":11}},\"swaps\":[{\"txHash\":\"1pyS...GxvXa\",\"date\":\"2026-07-08T18:20:15Z\",\"type\":\"REGULAR\",\"platform\":\"Padre\",\"baseMint\":\"8DhB...pump\",\"quoteMint\":\"So11...1112\",\"amountBase\":1646405.92,\"amountQuote\":0.862,\"amountUSD\":0,\"totalFeesUSD\":0.7491,\"gasFeesUSD\":0.0081,\"platformFeesUSD\":0.6638,\"mevFeesUSD\":0.0772}]}",
     "id": "get-api-v2-wallets-wallet-swaps",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/sandwich-rollup",
     "name": "MEV sandwich loss rollup",
     "summary": "How much a wallet lost to MEV sandwich bots across its recent swaps. Scans the wallet's recent swaps, keeps only the provider-flagged SANDWICHED ones (cap 25), and quantifies each via the per-tx MEV forensics engine (self-memoized per signature 24h) — returning confirmed sandwich events with victim loss, attacker, and extra slippage, plus a total. Route memoized 60s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58). 400 \"invalid wallet\" if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "40",
       "description": "How many recent swaps to scan (clamped 1-80). Only provider-flagged sandwiches among them are quantified (max 25 signatures)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"F7RV6aBWfniixoFkQNWmRwznDj2vae2XbusFfvMMjtbE\",\"swapsScanned\":40,\"providerFlagged\":2,\"confirmed\":1,\"totalVictimLossQuote\":0.0184,\"events\":[{\"signature\":\"4xk9Qh…Qm\",\"confidence\":\"high\",\"attacker\":\"J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCm\",\"victimLossQuote\":0.0184,\"attackerProfitQuote\":0.0121,\"extraSlippagePct\":3.7}]}",
     "responseNotes": "swapsScanned = swaps pulled for the wallet; providerFlagged = how many carried a SANDWICH tag; confirmed = how many the on-chain forensics engine actually confirmed as sandwiches; totalVictimLossQuote = summed victim loss across confirmed events, in the QUOTE asset (SOL or USDC of the sandwiched pair). Each events[] item: {signature, confidence, attacker, victimLossQuote, attackerProfitQuote, extraSlippagePct}. events is [] (and totals 0) when nothing is flagged/confirmed — verified live against an active trader (swapsScanned:40, providerFlagged:0, confirmed:0, events:[]); the populated event above is illustrative of the shape.",
     "errorCodes": [
      "400 invalid wallet",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 swaps provider not configured",
      "502 { success:false, error } — swaps/forensics upstream failure"
     ],
     "notes": "Cache key sandwichRollup:<wallet>:<limit>, TTL 60s. Deliberately narrow provider fan-out: only provider-tagged sandwiches are re-analyzed (max 25 sigs), and the per-tx engine self-memoizes each signature 24h, so a rollup is cheap after the first pass.",
     "id": "get-api-v2-wallets-wallet-sandwich-rollup"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/{wallet}/informed",
     "name": "Informed-buyer timing analysis",
     "summary": "Measures whether a wallet's ENTRIES are consistently well timed: for each recent buy, compares the executed price against the peak of the following window (default 3h) and reports how often that move cleared the pump threshold (default +50%). Flags a wallet only when BOTH a minimum number of well-timed entries AND a minimum hit rate are met, so a prolific trader who caught a few pumps by volume alone does not qualify. Correlation, not proof — a skilled momentum trader scores the same as someone acting on information. Coverage fields report how much of the wallet's history could actually be priced; a low `coverage` means the verdict is inconclusive rather than clean. Cached 30 min per wallet + threshold set.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "required": true,
       "description": "Base58 wallet address."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"ok\": true, \"wallet\": \"52gGDyhdWLFDMAHvNsAxwYLNkQauVwQWJUNBmmRt7KJh\", \"informed\": false, \"evaluated\": 22, \"buysConsidered\": 38, \"unpriced\": 1, \"wins\": 1, \"winRate\": 0.045, \"coverage\": 0.58, \"medianForwardPct\": 0.39, \"medianSamplesPerWindow\": 13, \"distinctTokens\": 11, \"windowHours\": 3, \"pumpPct\": 50, \"minWins\": 5, \"minRate\": 0.6, \"truncatedBuys\": 0, \"truncatedClusters\": 0, \"samples\": [ { \"mint\": \"h5NciPdMZ5QCB5BYETJMYBMpVx9ZuitR6HcVjyBhood\", \"symbol\": \"HOOD\", \"ts\": 1783521690040, \"usd\": 146.91, \"entry\": 6.1888e-06, \"peak\": 9.3972e-06, \"forwardPct\": 51.8, \"tx\": \"4PNaN4msGNGaWaurKx3kKyLc5Esgp27ptzuYfgtF9iS6X4zpoeG64AiMM31qGP7ub83QFWEwB1gyapyLCqJdCdbo\" } ], \"note\": null\n}",
     "responseNotes": [
      "`informed` is true only when evaluated >= minEvaluated AND wins >= minWins AND winRate >= minRate.",
      "`coverage` = evaluated / buysConsidered. Below ~0.5 treat the result as inconclusive, not clean.",
      "`unpriced` counts buys excluded because their forward window held too few price samples to trust.",
      "`medianSamplesPerWindow` is a data-quality readout — single-digit values mean thin price history.",
      "Thresholds (window length, pump %, minimum wins and hit rate) are operator-tunable and are echoed in every response, so a client can always see which criteria produced the verdict rather than assuming defaults."
     ],
     "errorCodes": [
      "400 invalid wallet",
      "503 trades provider not configured (the market-data layer key missing)",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream the market-data layer error (known to occasionally 503 upstream)"
     ],
     "notes": "Costs one trade-tape fetch plus one price series per narrow time-cluster of the wallet's buys; typically 2-4s. Intended for lazy per-wallet inspection, not bulk scanning.",
     "id": "wallets-informed"
    },
    {
     "method": "POST",
     "path": "/api/v2/wallets/cohort",
     "name": "Wallet cohort analysis (submit)",
     "summary": "Finds on-chain links between an ARBITRARY set of up to 100 wallets — no token mint involved. Traces each wallet's funding chain, direct transfers, shared funders, shared counterparties, common collectors and portfolio overlap, then clusters them into entities. Returns 202 with a jobId; poll GET /api/v2/wallets/cohort/{jobId} for progress and the result. A 100-wallet run typically completes in 15-60s. Identical address sets submitted within 10 minutes reuse the previous result (cached:true), and a concurrent identical submission joins the in-flight job instead of duplicating cost.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "addresses",
       "type": "string[] | string",
       "required": true,
       "description": "The wallets to analyse. Either an array of base58 pubkeys, or a single string with one address per line / comma / semicolon / whitespace separated (so a pasted CSV column or spreadsheet cell works). Duplicates are removed, unreadable entries are reported back in parseInfo rather than silently dropped, and the list is capped at COHORT_MAX_WALLETS (default 100). Minimum 2 valid addresses."
      },
      {
       "name": "refresh",
       "type": "boolean",
       "required": false,
       "description": "Set true to bypass the 10-minute result reuse and force a fresh analysis of the same address set."
      }
     ],
     "responseExample": "{\"success\":true,\"jobId\":\"kix-j2nbvZ_x\",\"status\":\"queued\",\"progress\":{\"stage\":\"queued\",\"done\":0,\"total\":100,\"pct\":0},\"accepted\":100,\"queuePosition\":null,\"cached\":false,\"reused\":false,\"joined\":false,\"parseInfo\":{\"invalid\":[{\"value\":\"oops\",\"reason\":\"not_base58_pubkey\"}],\"duplicatesRemoved\":3,\"overflowDropped\":0}}"
    },
    {
     "method": "GET",
     "path": "/api/v2/wallets/cohort/{jobId}",
     "name": "Wallet cohort analysis (result)",
     "summary": "Polls a cohort analysis. While running, returns status + progress (stage, done/total, pct). When status is 'done', includes the full result: a verdict (plain-language headline + tone), a cohesion score with its three components, the node/edge graph, clusters, BRIDGES (external addresses that connect wallets you supplied but did not include), the funding timeline, and coverage. Wallets whose history could not be read are returned in `unknown` — NOT in `isolated` — so unreadable data is never presented as evidence of independence. Returns 404 if the job expired (20min TTL) or the service restarted; resubmit in that case.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "jobId",
       "type": "string",
       "required": true,
       "description": "The jobId returned by POST /api/v2/wallets/cohort."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"jobId\":\"kix-j2nbvZ_x\",\"status\":\"done\",\"progress\":{\"stage\":\"done\",\"pct\":100},\"elapsedMs\":15100,\"result\":{\"ok\":true,\"verdict\":{\"headline\":\"32 of 100 wallets form 5 groups\",\"tone\":\"warn\",\"detail\":\"68 wallets showed no link to any other. 17 external addresses connect members you did not supply.\"},\"cohesion\":{\"score\":49,\"reachPct\":56,\"largestGroupPct\":15,\"evidenceStrength\":0.77,\"linkedCount\":56,\"groupCount\":5,\"isolatedCount\":68,\"unknownCount\":0},\"nodes\":[{\"id\":\"7Bx...\",\"cohort\":true,\"kind\":\"wallet\",\"group\":0,\"isolated\":false,\"lamports\":41230000,\"degree\":3,\"funder\":\"9Fd...\",\"fundedAt\":1784500000,\"tokenCount\":12}],\"edges\":[{\"source\":\"7Bx...\",\"target\":\"9Fd...\",\"type\":\"shared_funder\",\"strength\":0.8,\"evidence\":{\"funder\":\"9Fd...\",\"siblings\":4,\"amount\":250000000}}],\"clusters\":[{\"groupIndex\":0,\"members\":[\"7Bx...\"],\"memberCount\":15,\"confidence\":\"high\"}],\"bridges\":[{\"address\":\"iGd...\",\"memberCount\":9,\"linkTypes\":[\"shared_funder\"],\"strength\":0.8}],\"isolated\":[\"5tz...\"],\"unknown\":[],\"timeline\":[{\"address\":\"7Bx...\",\"t\":1784500000,\"lamports\":250000000,\"group\":0}],\"coverage\":{\"traced\":100,\"readable\":100,\"holdingsRead\":87,\"hubsMeasured\":17,\"hubsInfra\":0,\"infraEdgesDropped\":0},\"edgeTypes\":{\"shared_funder\":30,\"funding_chain\":42},\"_degraded\":[]}}"
    }
   ],
   "label": "Wallets",
   "icon": "wallet"
  },
  {
   "group": "market",
   "groupSummary": "Macro Solana market data plus every price-history and candle (OHLCV) endpoint. Aggregate 24h DEX activity across Solana (lighthouse), the canonical SOL/USD spot price and its history, and OHLCV / chart data at the token, pool, and market level. Lighter than the per-token intel endpoints — fewer upstream round-trips, longer cache TTLs. Every route is a read-only GET under /api/v2, requires an X-API-Key header, counts against your per-key rate limit, and is served from an in-process TTL+LRU cache with in-flight de-duplication (lighthouse ~5 min, sol-price ~30 s).",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/market/lighthouse",
     "name": "Market lighthouse (24h Solana DEX stats)",
     "summary": "Returns aggregate 24h trading activity across Solana DEXs — total USD volume (and its 24h change), trade/buy/sell counts, and total fees paid. Sourced from the market-data layer's market/lighthouse endpoint, reshaped into a flat object and cached 5 minutes.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"volumeUSD24h\": 1284533019.42, \"volumeUSD24hChange\": -3.71, \"trades24h\": 18452310, \"buys24h\": 9421005, \"sells24h\": 9031305, \"feesPaidUSD24h\": 4123880.55, \"generatedAt\": 1749859200000\n}",
     "responseNotes": "Top-level keys come straight from the handler (the API layer lines 19-27) which maps the market-data layer's data.total.* sub-objects: volumeUSD24h = total.volumeUSD['24h']; volumeUSD24hChange = total.volumeUSDChange['24h'] (a percent change, can be negative); trades24h/buys24h/sells24h = total.trades|buys|sells ['24h'] (raw counts); feesPaidUSD24h = total.feesPaidUSD['24h']. Every field defaults to 0 if the upstream sub-key is missing, so the shape is stable even on partial upstream data. generatedAt is Date.now() captured when the cache entry was built (not per-request — value is reused for up to 5 minutes). success:true is spread alongside the data fields. Result is memoized under cache key 'lighthouse' for 5 minutes (300000 ms).",
     "errorCodes": [
      "401",
      "429",
      "502",
      "503"
     ],
     "notes": "503 { success:false, error:'lighthouse provider not configured' } is returned immediately if the platform config is unset (the market-data layer.configured() is false). 502 { success:false, error:<message> } on any upstream failure — the market-data layer non-2xx (thrown as 'the market-data layer market/lighthouse returned <status>'), a 20s AbortSignal timeout, or JSON parse error. 401 (missing/invalid X-API-Key) and 429 (per-key rate limit) are enforced by the validateApiKey/rate-limit middleware at the /api/v2 mount before the handler runs. No path params, query params, or request body. Underlying provider: the market-data layer.getLighthouse → GET https://api.the market-data layer.io/api/2/market/lighthouse. AMBIGUOUS ZEROS: each field defaults to 0 when its upstream sub-value is missing, so an all-zeros 200 can mean 'partial/empty upstream data' rather than genuinely zero volume.",
     "id": "get-api-v2-market-lighthouse"
    },
    {
     "method": "GET",
     "path": "/api/v2/market/sol-price",
     "name": "Canonical SOL/USD price",
     "summary": "Returns the current SOL/USD spot price with a multi-source fallback chain: 30s in-memory cache → the price store DB table (accepted up to 10 min old) → Jupiter lite price API → $120 hard fallback. Always resolves to a price.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"price\": 148.27, \"source\": \"db\", \"recordedAt\": 1749858960000, \"generatedAt\": 1749859200000\n}",
     "responseNotes": "Fields from the API layer lines 42-48: price (number, USD); source is one of 'db' | 'jupiter' | 'fallback' indicating which tier of the chain produced the value (DB row from the shared the price store table; live Jupiter lite-api fetch; or the $120 hard fallback constant); recordedAt is epoch-ms — for 'db' it's the row's recorded_at, for 'jupiter'/'fallback' it's Date.now() at fetch time; generatedAt is Date.now() at response build. price/source/recordedAt come from solPrice.getSolPrice (Stryke's engine), memoized in-process under key 'solPrice:v2' for 30 s. On a DB miss, a live Jupiter price is fetched and opportunistically written back to the DB (fire-and-forget) so the next caller sees it. When source='fallback', price is exactly 120.",
     "errorCodes": [
      "401",
      "429",
      "502"
     ],
     "notes": "getSolPrice is designed to never throw (every tier is wrapped and the chain ends in a $120 fallback), so in practice this endpoint returns 200 with success:true even when the DB and Jupiter are both down (source:'fallback', price:120). The 502 { success:false, error:<message> } catch branch exists in the handler but is effectively unreachable given the provider's swallow-all behavior — list it only as a theoretical upstream-error code. 401 (missing/invalid X-API-Key) and 429 (per-key rate limit) are enforced by middleware at the /api/v2 mount. No path params, query params, or request body. Tiers: in-memory memo (30s) → the database the price store latest row if <10min old → Jupiter GET https://lite-api.jup.ag/price/v3?ids=So111...112 (6s timeout) → 120 USD. FALLBACK PRICE: if both the price store and the live quote are unavailable, the endpoint returns price=120 with source='fallback' and HTTP 200 success:true. Always branch on `source` — a 'fallback' value is a hardcoded placeholder, not a live quote.",
     "id": "get-api-v2-market-sol-price"
    },
    {
     "method": "GET",
     "path": "/api/v2/market/sol-price/history",
     "name": "SOL/USD price history",
     "summary": "SOL/USD price time-series for charting, over a chosen range. Returns an array of [timestamp_ms, priceUsd] points (ascending), downsampled to at most 300 points. Range-specific cache TTLs.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "range",
       "type": "string",
       "required": false,
       "default": "24h",
       "description": "One of: 1h, 24h, 7d, 30d. Any other value falls back to 24h. Determines lookback + native candle period (1h→5m, 24h/7d→1h, 30d→1d) and cache TTL (1h→60s, 24h→5m, 7d→15m, 30d→30m)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"range\":\"24h\",\"points\":[[1783519200000,77.077],[1783522800000,76.621],[1783566000000,77.042]],\"generatedAt\":1783604500000}",
     "responseNotes": "points is an array of [t, p] pairs: t = epoch-ms, p = SOL/USD price (> 0), sorted ascending. Non-finite or non-positive samples are dropped. When the raw series exceeds 300 points it is evenly downsampled to 300, with the true last sample always kept as the \"now\" end. generatedAt = Date.now() at response build. range echoes the effective range after fallback.",
     "errorCodes": [
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "503 history provider not configured",
      "502 { success:false, error } — history upstream failure"
     ],
     "notes": "Cache key sol-price:history:<range> with the per-range TTL above; concurrent hot callers share one upstream fetch. Sibling of GET /api/v2/market/sol-price (current spot). For a live single price use the spot endpoint; use this only for the chart series.",
     "id": "get-api-v2-market-sol-price-history"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/chart",
     "name": "Token price chart (OHLCV / price history)",
     "summary": "Returns a time-series of {t,v} price points for a mint from the market-data layer market history, suitable for candle/line charts. Cached 30s per (mint,period,from,to).",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "period",
       "type": "string",
       "required": false,
       "default": "1d",
       "description": "Time bucket. Allowed: 1h, 1d, 7d, 30d, 90d, 365d. Any other value silently falls back to 1d."
      },
      {
       "name": "from",
       "type": "number",
       "required": false,
       "default": "null",
       "description": "Start timestamp (ms epoch, passed via Number()). Optional."
      },
      {
       "name": "to",
       "type": "number",
       "required": false,
       "default": "null",
       "description": "End timestamp (ms epoch, passed via Number()). Optional."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"period\":\"1d\",\"from\":null,\"to\":null,\"points\":[{\"t\":1718236800000,\"v\":0.00002098},{\"t\":1718240400000,\"v\":0.00002131},{\"t\":1718244000000,\"v\":0.00002145}]}",
     "responseNotes": "Top-level: mint, period (resolved/clamped; accepts `interval` as an alias), from, to (resolved window, ms), points[], resolution ('candles'|'resampled'). Each point: t (unix-ms) and v (price). Provider-agnostic via the shared resolver (price history sourced primarily from the market-data layer, with a free pool-OHLCV fallback so pump.fun mints chart). No 503 gate — serves from the free fallback when the primary key is absent/empty. Empty series → points:[].",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 all price providers failed (success:false, neutral error message)"
     ],
     "notes": "Cache key chart:<mint>:<period>:<from>:<to>, TTL 30s. period is whitelisted {1h,1d,7d,30d,90d,365d} (or `interval` alias); any other value silently falls back to '1d' (no 400). Vendor-neutral: no provider name in body or errors.",
     "id": "get-api-v2-tokens-mint-chart"
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/ohlcv",
     "name": "OHLCV candles",
     "summary": "Open/high/low/close price candles, resampled from the price series into a fixed number of equal-width time buckets over the requested period. Use this for charting libraries that expect candle arrays (the /chart endpoint returns a raw price line). Cached 30s.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid."
      }
     ],
     "queryParams": [
      {
       "name": "period",
       "type": "string",
       "required": false,
       "default": "1d",
       "description": "Look-back window. Allowed: 1h, 1d, 7d, 30d, 90d, 365d. Any other value falls back to 1d."
      },
      {
       "name": "buckets",
       "type": "integer",
       "required": false,
       "default": "80",
       "description": "Number of candles to resample the window into. Clamped to 10–300."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"period\":\"7d\",\"candles\":[{\"t\":1780851600000,\"o\":1.001118547486874,\"h\":1.001558704722558,\"l\":1.001056092751251,\"c\":1.001337928925851},{\"t\":1780912020000,\"o\":1.001262843209248,\"h\":1.001283457234663,\"l\":1.000806125232852,\"c\":1.00082984511353}]}",
     "responseNotes": "Provider-agnostic. Top-level adds resolution ('candles'|'resampled') + count + from/to. candles[] ascending by time, each { t (epoch ms), o, h, l, c (USD), v (USD volume | null) }. resolution 'candles' = REAL OHLCV+volume from the free pool-OHLCV fallback (covers pump.fun mints), passed through natively (NOT re-bucketed); 'resampled' = OHLC derived from market-data-layer price points (v:null) into `buckets` equal windows. Accepts `interval` as a period alias and from/to (ms) to override the window. No 503 gate; empty series → candles:[] (never 404). Vendor-neutral body+errors.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 all price providers failed (success:false, neutral error message)"
     ],
     "notes": "Cache key ohlcv:<mint>:<period>:<buckets>:<from>:<to>, TTL 30s. Window bounded by from/to (ms); the series is re-filtered to the window. buckets clamped [10,300] (resampled path only — native candles pass through). Provider order: market-data layer (primary) → free pool-OHLCV fallback → keyed last-resort. Vendor-neutral.",
     "id": "get-api-v2-tokens-mint-ohlcv"
    },
    {
     "method": "GET",
     "path": "/api/v2/market/pool-ohlcv",
     "name": "Per-pool OHLCV candles",
     "summary": "OHLCV candles for a SPECIFIC liquidity pool address (not the token aggregate). For a token live on several pools (e.g. a graduated pump.fun token on both PumpSwap and Raydium), chart the exact venue a user trades.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "pool",
       "type": "string",
       "required": true,
       "description": "Pool / pair address (base58). Get one from a token markets lookup."
      },
      {
       "name": "period",
       "type": "string",
       "required": false,
       "default": "1h",
       "description": "Candle period: 1m,5m,1h,1d,1w."
      },
      {
       "name": "amount",
       "type": "integer",
       "required": false,
       "default": "200",
       "description": "Candles to return. Max 2000."
      },
      {
       "name": "from",
       "type": "integer",
       "required": false,
       "description": "Start time (ms epoch)."
      },
      {
       "name": "to",
       "type": "integer",
       "required": false,
       "description": "End time (ms epoch)."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"pool\":\"3Uwf...2Noc\",\"period\":\"1h\",\"count\":2,\"candles\":[{\"t\":1783530000000,\"o\":0.00000401,\"h\":0.00000406,\"l\":0.00000392,\"c\":0.00000402,\"v\":22419.09}]}",
     "id": "get-api-v2-market-pool-ohlcv",
     "responseNotes": " Response is normalised (numbers coerced, vendor bloat stripped) and Solana-focused; includes a derived summary where applicable. A \"stale\":true flag appears if served from the resilience cache during an upstream hiccup."
    },
    {
     "method": "GET",
     "path": "/api/v2/tokens/{mint}/chart-source",
     "name": "Chart embed source resolver",
     "summary": "Resolves a ready-to-embed iframe src for a mint's chart, with provider failover: the DEX index (established DEX pools) first, then GeckoTerminal (pre-migration pump.fun bonding curves). Cached 10 min.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "SPL token mint address (base58). 400 'invalid mint' if invalid."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263\",\"available\":true,\"provider\":\"the DEX index\",\"src\":\"https://the DEX index.com/solana/AbCd...?embed=1&theme=dark&trades=0&info=0&chartLeftToolbar=0\",\"fullUrl\":\"https://the DEX index.com/solana/AbCd...\",\"pairAddress\":\"AbCd...\"}",
     "responseNotes": "On success with a resolvable pool: success, mint, available:true, provider ('the DEX index'|'geckoterminal'), src (embeddable iframe URL), fullUrl, pairAddress (the DEX index pairAddress or GeckoTerminal pool address). When no pool is found on either provider: {success:true, mint, available:false} (no provider/src). the DEX index wins if the mint has any own Solana pair (highest h24 volume); otherwise GeckoTerminal /networks/solana/tokens/{mint}/pools first pool is used.",
     "errorCodes": [
      "400 invalid mint",
      "401 missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "502 upstream error (success:false, error message)"
     ],
     "notes": "Cache key chartSrc:<mint>, TTL 10 min. GeckoTerminal fetch is a direct fetch() with a 6s AbortSignal timeout; failures fall through to available:false rather than erroring. returns {success:true, available:false} (HTTP 200) when no chartable pool exists — branch on `available`, not on HTTP status.",
     "id": "get-api-v2-tokens-mint-chart-source"
    }
   ],
   "label": "Market & Charts",
   "icon": "chart"
  },
  {
   "group": "whales",
   "groupSummary": "Smart-money / \"whale\" intelligence endpoints under /api/v2/whales. Two operations: (1) bulk-classify a list of wallets into smart-money label tags and known-entity metadata (the market-data layer-derived), and (2) discover whale wallets among a token's on-chain top holders by resolving the top-20 token accounts to owner wallets (the node RPC) and enriching them with the market-data layer labels. No curated/featured list is served — callers supply their own wallet seed lists. Both endpoints require an X-API-Key header (validateApiKey) and count against the shared v2 per-key rate limit (10 requests / 10 seconds, keyed on the X-API-Key header). The /discover/:mint result is memoized in-process for 5 minutes per mint. Both depend on upstream providers (the market-data layer for labels, Stryke's node layer for on-chain data); when a provider key is unconfigured the handler returns 503, and upstream/provider failures surface as 502.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/v2/whales/labels",
     "name": "Bulk classify wallet labels",
     "summary": "Bulk-classifies up to 100 wallet addresses, returning the market-data layer-derived smart-money flag tags (e.g. insider, dev, sniper, smart, fresh, bundler) plus known-entity name/type/logo and platform when available.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallets",
       "type": "string[]",
       "required": true,
       "description": "Array of Solana wallet base58 addresses to classify. Invalid addresses (must match base58 32-44 chars) are filtered out, and the list is truncated to the first 100 valid addresses. If zero valid addresses remain after filtering, the request is rejected with 400."
      }
     ],
     "responseExample": "{\"success\":true,\"count\":2,\"labels\":{\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\":{\"wallet\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"entity\":\"Wintermute\",\"entityType\":\"market_maker\",\"entityLogo\":\"https://logos.the market-data layer.io/wintermute.png\",\"platform\":\"Raydium\",\"flags\":[\"smart\",\"insider\"]},\"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\":{\"wallet\":\"7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU\",\"entity\":null,\"entityType\":null,\"entityLogo\":null,\"platform\":null,\"flags\":[\"sniper\"]}}}",
     "responseNotes": "success: always true on 200. count: number of wallets present in the labels map (= Object.keys(labels).length). labels: object keyed by wallet address; each value has wallet (address), entity (the market-data layer walletMetadata.entityName or null), entityType (walletMetadata.entityType or null), entityLogo (walletMetadata.entityLogo or null), platform (the market-data layer platform.name or null), and flags (the market-data layer labels[] array of smart-money tag strings, defaults to []). Only wallets the market-data layer returns data for appear in the map — addresses with no the market-data layer record are omitted, so count can be less than the number of wallets submitted.",
     "errorCodes": [
      "400 wallets[] required (max 100 valid addresses) — body.wallets missing/not an array or no valid addresses after filtering",
      "401 missing/invalid X-API-Key (validateApiKey: 'API key required. Include X-API-Key header.' or 'Invalid or inactive API key.')",
      "403 key domain restriction (request domain not in key's allowed_domains)",
      "429 rate limit exceeded (>10 requests / 10s per key)",
      "502 upstream the market-data layer error (handler catch → { success:false, error })",
      "503 labels provider not configured (the platform config unset)"
     ],
     "notes": "Validation: req.body.wallets must be an array; entries are filtered through isAddr (regex /^[1-9A-HJ-NP-Za-km-z]{32,44}$/) then.slice(0,100). Provider: calls the market-data layer.getWalletLabels which POSTs { walletAddresses } to the market-data layer /1/wallet/labels (15s timeout). Note the 503 'labels provider not configured' is returned via the bad() helper which produces { success:false, error } — code 503 despite the helper's default-400 signature. Not memoized (live each call). No request body size cap beyond the 100-address slice. Counts as one v2 rate-limited request regardless of wallet count. PARTIAL RESULTS: wallets with no entity record are omitted from the labels map, so count can be less than the number of addresses submitted; the address list is silently capped at the first 100.",
     "id": "post-api-v2-whales-labels"
    },
    {
     "method": "GET",
     "path": "/api/v2/whales/discover/{mint}",
     "name": "Discover whales in a token's top holders",
     "summary": "Finds whale wallets among a token's largest holders: fetches the top-20 token accounts via the node RPC, resolves them to owner wallets, dedupes, then (if the market-data layer is configured) enriches and keeps only owners that carry smart-money flags or a known entity name. Result is cached in-process for 5 minutes per mint.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Solana token mint address (base58, 32-44 chars). Validated with isAddr; an invalid value returns 400 'invalid mint'."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"count\":2,\"whales\":[{\"wallet\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"entity\":\"Jump Trading\",\"entityType\":\"market_maker\",\"entityLogo\":\"https://logos.the market-data layer.io/jump.png\",\"platform\":\"Orca\",\"flags\":[\"smart\"]},{\"wallet\":\"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM\",\"entity\":null,\"entityType\":null,\"entityLogo\":null,\"platform\":null,\"flags\":[\"insider\",\"bundler\"]}],\"generatedAt\":1718323200000}",
     "responseNotes": "success: always true on 200. mint: echoed input mint. count: number of whales in the array. whales: array of whale objects — when the market-data layer is configured, each has wallet, entity (entityName or null), entityType (or null), entityLogo (or null), platform (platform.name or null), and flags (labels[]); only owners with at least one label OR a known entityName are included. When the market-data layer is NOT configured, whales is the raw deduped owner list as [{ wallet, flags: [] }] with no entity fields. generatedAt: epoch ms when the (cached) payload was generated — present only on the normal path; absent on the early empty-result returns ({ mint, count:0, whales:[] } when there are zero top token accounts or zero resolvable owners). Payload is memoized 5 min per mint key (whales:disc:<mint>), so generatedAt reflects cache build time, not request time.",
     "errorCodes": [
      "400 invalid mint (mint param fails base58 validation)",
      "401 missing/invalid X-API-Key (validateApiKey)",
      "403 key domain restriction (request domain not in key's allowed_domains)",
      "429 rate limit exceeded (>10 requests / 10s per key)",
      "502 upstream error (handler catch → { success:false, error }) — e.g. the node RPC failure that escapes the inner.catch",
      "503 on-chain provider not configured (the platform config/the platform config unset)"
     ],
     "notes": "Pipeline: node.getTokenLargestAccounts(mint) → take first 20 token-account addresses → node.getMultipleAccounts(addresses, jsonParsed) → extract data.parsed.info.owner → dedupe (Set). If the market-data layer configured, the market-data layer.getWalletLabels(uniqOwners) filters to owners with labels.length>0 OR walletMetadata.entityName. Inner Stryke's node layer calls use.catch(()=>null) so a single provider hiccup degrades to an empty/owner-only result rather than throwing; an outer try/catch maps any thrown error to 502. Cached via cache.memo with TTL 5*60_000 ms and in-flight de-duplication (concurrent callers for the same mint share one upstream fetch). No query params or body. 503 'on-chain provider not configured' returned via bad() helper with explicit 503 code. PARTIAL / SILENT DEGRADE: only the top-20 token accounts are examined (a hard ceiling). A labels-provider hiccup degrades to an owner-only list (or empty) with HTTP 200 rather than erroring, so count can be 0 on a transient upstream issue.",
     "id": "get-api-v2-whales-discover-mint"
    },
    {
     "method": "GET",
     "path": "/api/v2/whales/featured",
     "name": "Featured whales directory",
     "summary": "Curated smart-money / whale directory, live-enriched via the market-data layer labels + portfolio. Returns net worth, asset count, top holdings and entity/flag metadata per wallet, filterable by category. Default category 'trader' so institutional CEX vaults don't dominate.",
     "authRequired": true,
     "queryParams": [
      {
       "name": "category",
       "type": "string",
       "description": "Filter: 'trader' (default), 'cex', 'mm', 'validator', or 'all'.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"category\":\"trader\",\"whales\":[{\"wallet\":\"DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj\",\"entity\":null,\"entityType\":null,\"entityLogo\":null,\"entityTwitter\":null,\"platform\":null,\"flags\":[\"smart\",\"pro_trader\"],\"label\":\"Pro trader\",\"category\":\"trader\",\"netWorthUSD\":4820000,\"assetCount\":37,\"topAssets\":[{\"symbol\":\"SOL\",\"logo\":\"https://...sol.png\",\"valueUSD\":2100000},{\"symbol\":\"JUP\",\"logo\":\"https://...jup.png\",\"valueUSD\":640000}]}],\"generatedAt\":1719446400000}",
     "responseNotes": "whales[] of {wallet, entity (the market-data layer entityName or null), entityType, entityLogo, entityTwitter, platform, flags[] (raw the market-data layer labels), label (curated seed tag), category ('trader'|'cex'|'mm'|'validator', re-derived from entityType/name), netWorthUSD (clamped), assetCount, topAssets[] (top 3 by value, {symbol, logo, valueUSD})}. Sorted by netWorthUSD desc. category='all' returns every seed wallet. generatedAt is epoch ms. Underlying enrichment memoized 120s (shared with /leaderboard/featured).",
     "errorCodes": [
      {
       "code": 503,
       "when": "featured provider (the market-data layer) not configured"
      },
      {
       "code": 502,
       "when": "the market-data layer labels/portfolio upstream failed"
      }
     ],
     "id": "whales-featured"
    },
    {
     "method": "GET",
     "path": "/api/v2/leaderboard/featured",
     "name": "Featured leaderboard",
     "summary": "Curated smart-money leaderboard ranking the same the market-data layer-enriched whale set as /whales/featured by net worth, reshaped into ranking rows. pnl30d/winRate are 0 placeholders — the market-data layer does not expose per-wallet realized PnL / win-rate here.",
     "authRequired": true,
     "queryParams": [
      {
       "name": "category",
       "type": "string",
       "description": "Filter: 'trader' (default), 'cex', 'mm', 'validator', or 'all'.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"category\":\"trader\",\"leaders\":[{\"wallet\":\"DfMxre4cKmvogbLrPigxmibVTTQDuzjdXojWzjCXXhzj\",\"handle\":\"Pro trader\",\"displayName\":\"Pro trader\",\"entityLogo\":null,\"flags\":[\"smart\",\"pro_trader\"],\"category\":\"trader\",\"totalValue\":4820000,\"pnl30d\":0,\"winRate\":0,\"tradeCount\":37}],\"generatedAt\":1719446400000}",
     "responseNotes": "leaders[] of {wallet, handle (entity or curated label), displayName (same), entityLogo, flags[], category, totalValue (=netWorthUSD), pnl30d (always 0 placeholder), winRate (always 0 placeholder), tradeCount (=assetCount)}. Only wallets with totalValue > 10 kept; sorted by totalValue desc. category='all' returns all. Reuses the /whales/featured 120s memo (one shared upstream fan-out). generatedAt is epoch ms.",
     "errorCodes": [
      {
       "code": 503,
       "when": "leaderboard provider (the market-data layer) not configured"
      },
      {
       "code": 502,
       "when": "the market-data layer labels/portfolio upstream failed"
      }
     ],
     "id": "leaderboard-featured"
    }
   ],
   "label": "Whales",
   "icon": "whale"
  },
  {
   "group": "trading",
   "groupSummary": "Swap, limit-order, and DCA endpoints backed by Jupiter's lite-api (lite-api.jup.ag). Two API routers — `swap` (mounted at /api/v2/swap) for quote + build, and `orders` (mounted at /api/v2/orders) for limit orders and dollar-cost-average (DCA) positions. Every endpoint returns UNSIGNED base64 transactions where applicable; the caller signs locally with the user's wallet and broadcasts themselves — the API never sees or holds private keys. All responses are wrapped in `{ success: true,... }` on success (the Jupiter upstream payload is spread in, or nested under `quote` for the quote endpoint). Validation failures return HTTP 400 `{ success:false, error }`; any Jupiter upstream non-2xx or timeout is surfaced as HTTP 502 `{ success:false, error }`. All endpoints require the `X-API-Key` header (validateApiKey) and count against the per-key rate limit (apiLimiter: 10 requests / 10s window, keyed by API key). Address fields are validated against a base58 Solana address regex (32-44 chars). Note: the response field names below are Jupiter lite-api passthrough shapes (swap/v1, limit/v2, dca/v1) — they are not remapped by this service.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/swap/quote",
     "name": "Get swap quote",
     "summary": "Returns the best Jupiter swap route/quote for swapping `amount` base units of `inputMint` into `outputMint`. The returned quote object is required as input to POST /api/v2/swap/build.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "inputMint",
       "type": "string",
       "required": true,
       "default": "",
       "description": "Mint address of the input (sell) token. Must be a valid base58 Solana address (32-44 chars). E.g. So11111111111111111111111111111111111111112 for SOL."
      },
      {
       "name": "outputMint",
       "type": "string",
       "required": true,
       "default": "",
       "description": "Mint address of the output (buy) token. Must be a valid base58 Solana address."
      },
      {
       "name": "amount",
       "type": "integer",
       "required": true,
       "default": "",
       "description": "Input amount in the token's smallest base units (e.g. lamports for SOL). Parsed with Number(); must be finite and > 0."
      },
      {
       "name": "slippageBps",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Allowed slippage in basis points. Coerced via Number() then clamped to [1, 10000]; falsy/NaN falls back to 50 (0.5%)."
      },
      {
       "name": "platformFeeBps",
       "type": "integer",
       "required": false,
       "default": "(none)",
       "description": "Optional platform fee in basis points to embed in the quote. When provided, clamped to [0, 2500]; omitted from the upstream request when absent."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"quote\":{\"inputMint\":\"So11111111111111111111111111111111111111112\",\"inAmount\":\"100000000\",\"outputMint\":\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\",\"outAmount\":\"14982317\",\"otherAmountThreshold\":\"14907405\",\"swapMode\":\"ExactIn\",\"slippageBps\":50,\"platformFee\":null,\"priceImpactPct\":\"0.0012\",\"routePlan\":[{\"swapInfo\":{\"ammKey\":\"5quB...\",\"label\":\"Whirlpool\",\"inputMint\":\"So111...112\",\"outputMint\":\"EPjF...Dt1v\",\"inAmount\":\"100000000\",\"outAmount\":\"14982317\",\"feeAmount\":\"3000\",\"feeMint\":\"So111...112\"},\"percent\":100}],\"contextSlot\":293847123,\"timeTaken\":0.0123}}",
     "responseNotes": "`quote` is the verbatim Jupiter swap/v1 quote object. Key fields: `inAmount`/`outAmount` (base-unit strings), `otherAmountThreshold` (min received after slippage), `swapMode` (ExactIn/ExactOut), `slippageBps`, `priceImpactPct`, `platformFee` (null unless platformFeeBps was passed), and `routePlan[]` describing the AMM hops. The ENTIRE `quote` object must be passed back as `quoteResponse` to /swap/build.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: bad/missing inputMint or outputMint -> 400 'inputMint, outputMint required'; non-finite or <=0 amount -> 400 'amount (in base units) required'. Upstream call has an 8s timeout; any Jupiter non-2xx or timeout -> 502 with the thrown error message (e.g. 'Jupiter swap/quote returned 4xx'). Proxies https://lite-api.jup.ag/swap/v1/quote (override via JUP_SWAP_BASE).",
     "id": "get-api-v2-swap-quote"
    },
    {
     "method": "POST",
     "path": "/api/v2/swap/build",
     "name": "Build swap transaction",
     "summary": "Builds an UNSIGNED base64 swap transaction from a quote (from /swap/quote) and the user's public key. The caller signs and broadcasts it themselves; the API never holds keys.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "quoteResponse",
       "type": "object",
       "required": true,
       "description": "The full quote object returned by GET /swap/quote (the value of its `quote` field). Must be a non-null object."
      },
      {
       "name": "userPublicKey",
       "type": "string",
       "required": true,
       "description": "The wallet public key that will sign and own the swap. Must be a valid base58 Solana address."
      },
      {
       "name": "feeAccount",
       "type": "string",
       "required": false,
       "description": "Optional token account to collect the platform fee (must correspond to the platformFeeBps used in the quote). If present, must be a valid base58 address (else 400)."
      },
      {
       "name": "computeUnitPriceMicroLamports",
       "type": "integer|string",
       "required": false,
       "description": "Optional priority fee (compute unit price) in micro-lamports. Forwarded to Jupiter only when truthy."
      }
     ],
     "responseExample": "{\"success\":true,\"swapTransaction\":\"AQAAAAAAAAAA...base64-unsigned-vtx...\",\"lastValidBlockHeight\":293848000,\"prioritizationFeeLamports\":12000,\"computeUnitLimit\":140000,\"dynamicSlippageReport\":null}",
     "responseNotes": "Response spreads Jupiter swap/v1 /swap output alongside `success`. Primary field: `swapTransaction` — a base64-encoded UNSIGNED versioned transaction. Also typically includes `lastValidBlockHeight`, `prioritizationFeeLamports`, `computeUnitLimit`, and (when dynamic slippage is used) `dynamicSlippageReport`. The service hardcodes wrapAndUnwrapSol:true, useSharedAccounts:false, dynamicComputeUnitLimit:true on the upstream request.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: missing/non-object quoteResponse -> 400 'quoteResponse object required'; invalid userPublicKey -> 400 'userPublicKey required'; non-address feeAccount -> 400 'feeAccount must be a valid address'. Upstream timeout 12s; Jupiter non-2xx/timeout -> 502. Proxies POST https://lite-api.jup.ag/swap/v1/swap (JUP_SWAP_BASE).",
     "id": "post-api-v2-swap-build"
    },
    {
     "method": "POST",
     "path": "/api/v2/orders/limit/create",
     "name": "Create limit order",
     "summary": "Creates a Jupiter limit order and returns the UNSIGNED transaction to open it. Caller signs and broadcasts.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "inputMint",
       "type": "string",
       "required": true,
       "description": "Mint to sell. Valid base58 Solana address."
      },
      {
       "name": "outputMint",
       "type": "string",
       "required": true,
       "description": "Mint to buy. Valid base58 Solana address."
      },
      {
       "name": "makingAmount",
       "type": "string|number",
       "required": true,
       "description": "Amount of inputMint to sell, in base units. Validated with Number() (must be finite)."
      },
      {
       "name": "takingAmount",
       "type": "string|number",
       "required": true,
       "description": "Amount of outputMint to receive, in base units (defines the limit price). Validated with Number() (must be finite)."
      },
      {
       "name": "maker",
       "type": "string",
       "required": false,
       "description": "Wallet that owns the order. If provided, must be a valid base58 address (else 400). Forwarded to Jupiter."
      },
      {
       "name": "payer",
       "type": "string",
       "required": false,
       "description": "Wallet that pays rent/fees for creating the order. Forwarded to Jupiter as-is (not address-validated by this service)."
      }
     ],
     "responseExample": "{\"success\":true,\"order\":\"7Yk2...orderPubkey\",\"tx\":\"AQAAAAAA...base64-unsigned-tx...\"}",
     "responseNotes": "Spreads Jupiter limit/v2 createOrder output alongside `success`. Returns the new `order` account public key and the base64 UNSIGNED transaction (Jupiter returns this as `tx`) to open it. Exact field names are Jupiter lite-api limit/v2 passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: bad inputMint/outputMint -> 400 'inputMint, outputMint required'; non-finite makingAmount/takingAmount -> 400 'makingAmount, takingAmount (base units) required'; non-address maker -> 400 'maker must be valid address'. Upstream timeout 12s. Proxies POST https://lite-api.jup.ag/limit/v2/createOrder (JUP_LIMIT_BASE).",
     "id": "post-api-v2-orders-limit-create"
    },
    {
     "method": "GET",
     "path": "/api/v2/orders/limit",
     "name": "List open limit orders",
     "summary": "Lists a wallet's currently open Jupiter limit orders.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "wallet",
       "type": "string",
       "required": true,
       "default": "",
       "description": "Wallet address whose open limit orders to fetch. Must be a valid base58 Solana address."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"orders\":[{\"publicKey\":\"7Yk2...\",\"account\":{\"maker\":\"9xQe...\",\"inputMint\":\"So111...112\",\"outputMint\":\"EPjF...Dt1v\",\"makingAmount\":\"100000000\",\"takingAmount\":\"15000000\",\"oriMakingAmount\":\"100000000\",\"oriTakingAmount\":\"15000000\",\"expiredAt\":null,\"createdAt\":\"2026-06-14T10:00:00Z\"}]}}",
     "responseNotes": "Spreads Jupiter limit/v2 openOrders output alongside `success` (typically an `orders` array of {publicKey, account:{maker, inputMint, outputMint, makingAmount, takingAmount,...}}). Shape is the Jupiter lite-api openOrders passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: invalid/missing wallet -> 400 'wallet required'. wallet is URL-encoded into the upstream query. Default 8s timeout. Proxies GET https://lite-api.jup.ag/limit/v2/openOrders?wallet=... (JUP_LIMIT_BASE).",
     "id": "get-api-v2-orders-limit"
    },
    {
     "method": "GET",
     "path": "/api/v2/orders/limit/history",
     "name": "Limit order history",
     "summary": "Returns a wallet's historical (filled/cancelled/expired) Jupiter limit orders.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "wallet",
       "type": "string",
       "required": true,
       "default": "",
       "description": "Wallet address whose limit-order history to fetch. Must be a valid base58 Solana address."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"orders\":[{\"publicKey\":\"7Yk2...\",\"status\":\"Completed\",\"inputMint\":\"So111...112\",\"outputMint\":\"EPjF...Dt1v\",\"makingAmount\":\"100000000\",\"takingAmount\":\"15000000\",\"trades\":[{\"amountIn\":\"100000000\",\"amountOut\":\"15010000\",\"txId\":\"5x...\",\"confirmedAt\":\"2026-06-14T10:05:00Z\"}],\"createdAt\":\"2026-06-14T10:00:00Z\"}]}}",
     "responseNotes": "Spreads Jupiter limit/v2 orderHistory output alongside `success` (typically an `orders` array including `status` and a `trades[]` fill log). Exact shape is the Jupiter lite-api orderHistory passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: invalid/missing wallet -> 400 'wallet required'. Default 8s timeout. Proxies GET https://lite-api.jup.ag/limit/v2/orderHistory?wallet=... (JUP_LIMIT_BASE).",
     "id": "get-api-v2-orders-limit-history"
    },
    {
     "method": "POST",
     "path": "/api/v2/orders/limit/cancel",
     "name": "Cancel limit order",
     "summary": "Builds an UNSIGNED transaction to cancel an existing Jupiter limit order. Caller signs and broadcasts.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "order",
       "type": "string|object",
       "required": true,
       "description": "The limit order to cancel — the order account public key (or order object) returned by /limit/create or /limit. Required; missing -> 400. Forwarded to Jupiter as the request body field `order`."
      },
      {
       "name": "maker",
       "type": "string",
       "required": false,
       "description": "The order maker / owner wallet. Forwarded to Jupiter as-is (not address-validated by this service)."
      }
     ],
     "responseExample": "{\"success\":true,\"tx\":\"AQAAAAAA...base64-unsigned-cancel-tx...\"}",
     "responseNotes": "Spreads Jupiter limit/v2 cancelOrder output alongside `success`. Returns the base64 UNSIGNED cancellation transaction (Jupiter `tx`). Field names are the Jupiter lite-api passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: missing order -> 400 'order object required'. No address validation is performed on order/maker by this service. Default 12s timeout. Proxies POST https://lite-api.jup.ag/limit/v2/cancelOrder (JUP_LIMIT_BASE).",
     "id": "post-api-v2-orders-limit-cancel"
    },
    {
     "method": "POST",
     "path": "/api/v2/orders/dca/create",
     "name": "Create DCA position",
     "summary": "Creates a Jupiter dollar-cost-average (DCA) position and returns the UNSIGNED transaction to open it. Caller signs and broadcasts.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "inputMint",
       "type": "string",
       "required": true,
       "description": "Mint to spend each cycle. Valid base58 Solana address."
      },
      {
       "name": "outputMint",
       "type": "string",
       "required": true,
       "description": "Mint to accumulate. Valid base58 Solana address."
      },
      {
       "name": "inAmount",
       "type": "string|number",
       "required": true,
       "description": "Total input amount to DCA across all cycles, in base units. Validated with Number() (must be finite)."
      },
      {
       "name": "cycleSecondsApart",
       "type": "string|number",
       "required": true,
       "description": "Seconds between each buy cycle. Validated with Number() (must be finite)."
      },
      {
       "name": "numberOfCycles",
       "type": "string|number",
       "required": true,
       "description": "Number of buy cycles to execute. Validated with Number() (must be finite)."
      },
      {
       "name": "user",
       "type": "string",
       "required": false,
       "description": "Wallet that owns the DCA position. If provided, must be a valid base58 address (else 400). Forwarded to Jupiter."
      }
     ],
     "responseExample": "{\"success\":true,\"dca\":\"3Pq8...dcaPubkey\",\"tx\":\"AQAAAAAA...base64-unsigned-tx...\"}",
     "responseNotes": "Spreads Jupiter dca/v1 createDca output alongside `success`. Returns the new `dca` position account public key and the base64 UNSIGNED transaction (Jupiter `tx`) to open it. Field names are the Jupiter lite-api dca/v1 passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: bad inputMint/outputMint -> 400 'inputMint, outputMint required'; any non-finite inAmount/cycleSecondsApart/numberOfCycles -> 400 'inAmount, cycleSecondsApart, numberOfCycles required'; non-address user -> 400 'user must be valid address'. Default 12s timeout. Proxies POST https://lite-api.jup.ag/dca/v1/createDca (JUP_DCA_BASE).",
     "id": "post-api-v2-orders-dca-create"
    },
    {
     "method": "GET",
     "path": "/api/v2/orders/dca",
     "name": "List DCA positions",
     "summary": "Lists a wallet's Jupiter DCA positions.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "wallet",
       "type": "string",
       "required": true,
       "default": "",
       "description": "Wallet address whose DCA positions to fetch. Must be a valid base58 Solana address."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"dcaAccounts\":[{\"publicKey\":\"3Pq8...\",\"account\":{\"user\":\"9xQe...\",\"inputMint\":\"So111...112\",\"outputMint\":\"EPjF...Dt1v\",\"inAmount\":\"1000000000\",\"inAmountPerCycle\":\"100000000\",\"cycleFrequency\":\"3600\",\"nextCycleAt\":\"2026-06-14T11:00:00Z\",\"inDeposited\":\"1000000000\",\"inWithdrawn\":\"0\",\"outWithdrawn\":\"0\"}}]}}",
     "responseNotes": "Spreads Jupiter dca/v1 positions output alongside `success` (typically a positions array of {publicKey, account:{user, inputMint, outputMint, inAmountPerCycle, cycleFrequency, nextCycleAt,...}}). Shape is the Jupiter lite-api dca/v1 positions passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: invalid/missing wallet -> 400 'wallet required'. Default 8s timeout. Proxies GET https://lite-api.jup.ag/dca/v1/positions?wallet=... (JUP_DCA_BASE).",
     "id": "get-api-v2-orders-dca"
    },
    {
     "method": "POST",
     "path": "/api/v2/orders/dca/close",
     "name": "Close DCA position",
     "summary": "Builds an UNSIGNED transaction to close an existing Jupiter DCA position and withdraw remaining funds. Caller signs and broadcasts.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "dca",
       "type": "string|object",
       "required": true,
       "description": "The DCA position to close — the position account public key (or object) returned by /dca/create or /dca. Required; missing -> 400. Forwarded to Jupiter as the request body field `dca`."
      },
      {
       "name": "user",
       "type": "string",
       "required": false,
       "description": "The DCA position owner wallet. Forwarded to Jupiter as-is (not address-validated by this service)."
      }
     ],
     "responseExample": "{\"success\":true,\"tx\":\"AQAAAAAA...base64-unsigned-close-tx...\"}",
     "responseNotes": "Spreads Jupiter dca/v1 closeDca output alongside `success`. Returns the base64 UNSIGNED close transaction (Jupiter `tx`). Field names are the Jupiter lite-api passthrough.",
     "errorCodes": [
      "400",
      "401",
      "403",
      "429",
      "502",
      "500"
     ],
     "notes": "Validation: missing dca -> 400 'dca object required'. No address validation on dca/user by this service. Default 12s timeout. Proxies POST https://lite-api.jup.ag/dca/v1/closeDca (JUP_DCA_BASE).",
     "id": "post-api-v2-orders-dca-close"
    }
   ],
   "label": "Trading",
   "icon": "bolt"
  },
  {
   "group": "launches",
   "groupSummary": "Recent Solana token launches, proxied through Stryke's on-chain indexer (HTTP GET /v1/indexer/recent-pools on Stryke-trading-api, real-time gRPC-fed). The router exposes a single read endpoint that returns the most recently indexed liquidity pools / launches, optionally filtered by DEX. Results are sorted indexed_at DESC and briefly memoized (5s) per DEX bucket. Mounted at /api/v2/launches.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/launches/recent",
     "name": "List recent launches",
     "summary": "Returns the most recently indexed token launches (newly created liquidity pools) sorted by indexed_at descending, optionally filtered to a single DEX. Backed by the trading-bot indexer's /v1/indexer/recent-pools, cached in-process for 5 seconds per DEX bucket.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "limit",
       "type": "integer",
       "required": false,
       "default": "50",
       "description": "Number of launches to return. Coerced via Number(); non-numeric / missing falls back to 50, then clamped to the inclusive range [1, 200] (Math.max(1, Math.min(200,...))). The upstream is always queried at the max (200) and the result is sliced on read, so e.g. limit=49 and limit=50 share one upstream call/cache entry."
      },
      {
       "name": "dex",
       "type": "string",
       "required": false,
       "default": "",
       "description": "Filter to a single DEX. Lowercased before validation and must be one of an allow-list: pumpfun, pumpswap, raydium, raydium_cpmm, meteora, meteora_damm_v2, orca, bonk. Any other non-empty value returns 400. Omitted/empty means all DEXes (cache bucket 'all'). Sent upstream as dex_type."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":2,\"launches\":[{\"mint\":\"7xKp9aF3qN8mD2vR4tWcYb1eS6hJ5gL0uZ3nX8kQwAp\",\"poolAddress\":\"9mB2cV4nR6tY8uI1oP3aS5dF7gH9jK0lZ2xC4vB6nM8q\",\"dexType\":\"pumpfun\",\"signature\":\"4nQ8xZ2kL9mP1vR3tW5cY7bH6gJ0sD8fA2eS4uI6oK1pX3vB5nM7qR9tY2uI4oP6aS8\",\"blockTime\":1749900000,\"indexedAt\":1749900003,\"creator\":\"GkP3vN8mD2qR4tWcYb1eS6hJ5gL0uZ3nX8kQwApF7xK\",\"name\":\"Example Coin\",\"symbol\":\"EXMPL\",\"uri\":\"https://ipfs.io/ipfs/Qm.../meta.json\"},{\"mint\":\"2vB5nM7qR9tY2uI4oP6aS8dF1gH3jK5lZ7xC9vB2nM4q\",\"poolAddress\":\"5dF7gH9jK0lZ2xC4vB6nM8qR1tY3uI5oP7aS9dF2gH4j\",\"dexType\":\"raydium_cpmm\",\"signature\":null,\"blockTime\":null,\"indexedAt\":1749899987,\"creator\":null,\"name\":null,\"symbol\":null,\"uri\":null}]}",
     "responseNotes": "Top-level keys: success (always true on 200), count (length of the returned launches array after limit slicing), launches (array). Each launch object is produced by normaliseLaunchRow in Stryke's engine with these fields: mint (string, token mint address); poolAddress (string, mapped from upstream pool_address); dexType (string, from dex_type — one of the allow-list values); signature (string|null, creation tx signature); blockTime (number|null, on-chain block time as unix seconds, Number()-coerced); indexedAt (number, unix timestamp when the indexer recorded the pool, Number()-coerced, the DESC sort key); creator (string|null, creator wallet); name (string|null, empty strings coalesced to null); symbol (string|null); uri (string|null, metadata URI). If the upstream returns success!==true or a non-array data, or the call times out (4s) / fails, getRecentLaunches degrades to an empty array — so a 200 with count:0 and launches:[] is the normal 'no results / soft upstream degrade' shape rather than an error.",
     "errorCodes": [
      "400 — dex provided but not in the allow-list; body {success:false, error:\"dex must be one of: pumpfun, pumpswap, raydium, raydium_cpmm, meteora, meteora_damm_v2, orca, bonk\"}",
      "401 — missing or invalid X-API-Key (validateApiKey middleware, applied to all /api/v2 routes except /health)",
      "429 — per-API-key rate limit exceeded",
      "502 — upstream/indexer error thrown during fetch or cache.memo; body {success:false, error:<err.message>}",
      "503 — indexer not configured (the platform config unset, the indexer.configured() false); body {success:false, error:\"indexer not configured\"}"
     ],
     "notes": "Only router.METHOD in the API layer — the file mounts exactly this one GET handler (module.exports = router after a single router.get('/recent',...)). Implementation chain: route → cache.memo(`launches:${dex||'all'}`, 5000ms) → the indexer.getRecentLaunches(res, {limit:200, dexType:dex}) → trackedFetch GET {sk_TRADING_API_URL||http://127.0.0.1:3488}/v1/indexer/recent-pools?limit=200[&dex_type=...] with Bearer the platform config, 4s AbortSignal.timeout → body.data.map(normaliseLaunchRow). Caching detail: the upstream is always called at MAX_LIMIT=200 and cached per DEX bucket; the handler slices to `limit` on read (data = limit < all.length ? all.slice(0, limit) : all), so the cached payload and count reflect the requested limit, not 200. The dex value is part of the cache key, so each DEX (and 'all') has its own 5s entry. trackedFetch threads the call into per-API-key telemetry (the API layer) under provider 'indexer', endpoint 'recent-pools', contributing to provider_calls. Note the upstream itself also re-validates dex_type against its full synonym table — the local allow-list is just a fast-fail to avoid a roundtrip on clearly-invalid input. UPSTREAM DEGRADE: if the launch indexer is down or times out, the endpoint returns HTTP 200 with count:0 and launches:[]. A zero count can mean 'no recent launches' OR 'upstream unavailable' — the two are not distinguishable from the response alone.",
     "id": "get-api-v2-launches-recent"
    }
   ],
   "label": "Launches",
   "icon": "rocket"
  },
  {
   "group": "rpc",
   "groupSummary": "Generic, read-only Solana JSON-RPC passthrough to Stryke's node layer mainnet, exposed under /api/v2/rpc. It replaces Stryke's previously client-exposed /api/node-rpc proxy: the same allow-list of safe, read-only RPC methods (no transaction submission, signing, or subscriptions), with per-API-key cost attribution of the upstream Stryke's node layer spend via trackedFetch. Accepts a single JSON-RPC call or a batch (max 25), forwards the body verbatim to https://mainnet.node-rpc.com, and pipes back Stryke's node layer's raw status, content-type, and body unchanged. There is exactly one route in the API layer (POST /). The unauthenticated GET /health lives in the API layer, not in this group.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/v2/rpc",
     "name": "JSON-RPC passthrough (Stryke's node layer mainnet)",
     "summary": "Forwards a single or batched (max 25) read-only Solana JSON-RPC request to Stryke's node layer mainnet and returns the upstream response verbatim. Only allow-listed read-only methods are permitted (no transaction submission, signing, or subscriptions).",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "(root)",
       "type": "object | object[]",
       "required": true,
       "description": "The JSON body is EITHER a single JSON-RPC 2.0 call object OR an array of such objects (batch). An array must contain 1..25 calls (MAX_BATCH=25); empty or >25 → 400. A null/absent body → 400 'json body required'. The whole body is JSON.stringify'd and forwarded verbatim to Stryke's node layer — Stryke's node layer returns results in the same single-vs-array shape as the request."
      },
      {
       "name": "method",
       "type": "string",
       "required": true,
       "description": "Per-call: the JSON-RPC method name. Must be a string (else 400 'each call needs a string method') and must be in the read-only allow-list (else 400 'method <m> not allowed'). Allowed: getAccountInfo, getMultipleAccounts, getProgramAccounts, getBalance, getTokenAccountBalance, getTokenAccountsByOwner, getTokenAccountsByDelegate, getTokenLargestAccounts, getTokenSupply, getSlot, getBlockHeight, getBlock, getEpochInfo, getLatestBlockhash, getRecentBlockhash, getMinimumBalanceForRentExemption, getSignatureStatuses, getTransaction, getTransactionCount, getInflationReward, getStakeActivation, getVoteAccounts, getClusterNodes, getHealth, getVersion, getGenesisHash, getIdentity, getAsset, getAssetBatch, getAssetsByOwner, getAssetsByGroup, getAssetsByAuthority, getAssetsByCreator, getAssetProof, getAssetProofBatch, searchAssets, getTokenAccounts, getNftEditions, getSignaturesForAsset, getSignaturesForAddress, simulateTransaction, getPriorityFeeEstimate, getTransfersByAddress."
      },
      {
       "name": "jsonrpc",
       "type": "string",
       "required": false,
       "description": "Per-call: JSON-RPC version, e.g. \"2.0\". Not validated by the handler (only `method` is checked) but forwarded verbatim to Stryke's node layer, which expects \"2.0\". Recommended to set it."
      },
      {
       "name": "id",
       "type": "string | number",
       "required": false,
       "description": "Per-call: client-chosen request id echoed back by Stryke's node layer in the matching response object. Forwarded verbatim; not validated."
      },
      {
       "name": "params",
       "type": "array | object",
       "required": false,
       "description": "Per-call: method-specific JSON-RPC params, forwarded verbatim to Stryke's node layer (shape depends on the method — e.g. [address, {encoding}] for getAccountInfo, or {id: mint} for getAsset). Not validated by this handler."
      }
     ],
     "responseExample": "{\"jsonrpc\":\"2.0\",\"id\":\"getBalance\",\"result\":{\"context\":{\"apiVersion\":\"2.0.15\",\"slot\":348925671},\"value\":1499995000}}",
     "responseNotes": "On success the handler does NOT wrap the result — it pipes Stryke's node layer's raw body straight through with res.status(r.status).type(content-type).send(text). For a single call you get one JSON-RPC object {jsonrpc, id, result}; for a batch request you get an ARRAY of such objects. A method-level RPC failure comes back as Stryke's node layer's standard {jsonrpc, id, error:{code, message}} (often still HTTP 200, since this is an RPC-protocol error, not an HTTP error). The `result` payload shape is entirely method-dependent (e.g. getAccountInfo → {context, value}, getAsset → a DAS asset object, getSignaturesForAddress → an array of signature info). Errors RAISED BY THIS HANDLER (not Stryke's node layer) use a different shape: {success:false, error:\"<message>\"} — e.g. 400 {\"success\":false,\"error\":\"method foo not allowed\"}, 503 {\"success\":false,\"error\":\"rpc provider not configured\"}, 502 {\"success\":false,\"error\":\"<fetch error message>\"}. Note: HTTP status from Stryke's node layer is propagated as-is, so a malformed upstream request can surface a 4xx/5xx from Stryke's node layer with a node-shaped JSON-RPC error body rather than the {success:false} shape.",
     "errorCodes": [
      "400 (json body required; batch must be 1..25; each call needs a string method; method <m> not allowed) — {success:false,error}",
      "401 (missing or invalid X-API-Key, via validateApiKey)",
      "429 (per-key rate limit exceeded, via apiLimiter)",
      "502 (upstream fetch to Stryke's node layer failed or timed out after 25s — {success:false,error:<message>})",
      "503 (rpc provider not configured — the platform config unset — {success:false,error:'rpc provider not configured'})",
      "5xx (any HTTP error status returned by Stryke's node layer is propagated verbatim with Stryke's node layer's JSON-RPC error body)"
     ],
     "notes": "Single POST handler at router.post('/') in the API layer; mounted at /rpc under the /api/v2 router (the API layer: router.use('/rpc', rpc)) → full path /api/v2/rpc. Constants: MAX_BATCH=25, TIMEOUT_MS=25000 (AbortSignal.timeout). Validation order: (1) node.configured() → 503 if the platform config/the platform config unset; (2) body null → 400; (3) calls normalized to array, length 0 or >25 → 400; (4) per-call method must be a string and in ALLOW set → 400. Forwarding: trackedFetch(res,'Stryke's node layer', node.rpcUrl(), {POST, headers Content-Type+Accept application/json, body=JSON.stringify(original body), 25s timeout}, {endpoint:'jsonrpc'}); rpcUrl() = https://mainnet.node-rpc.com/?api-key=<KEY>. trackedFetch attributes provider cost (Stryke's node layer = 50 micro-USD/call) and increments res.locals.providerCalls for the per-key analytics row written to the database internal storage. Auth + rate limit are applied centrally in the API layer (router.use(apiLimiter); router.use(validateApiKey)) before this sub-router, so every method here requires X-API-Key except the separate unauthenticated GET /api/v2/health. The Accept:application/json header on the outbound call is added here even though node.rpcCall() (used elsewhere) omits it — this route forwards the raw client body rather than reconstructing the JSON-RPC envelope. the request body is forwarded to the upstream RPC verbatim (only the top-level `method` is allow-listed); a method-level RPC error returns HTTP 200 with a JSON-RPC {error} body — inspect the body, not just the status. Batch is capped at 25.",
     "id": "post-api-v2-rpc"
    }
   ],
   "label": "RPC",
   "icon": "server"
  },
  {
   "label": "Streaming",
   "group": "streaming",
   "groupSummary": "Realtime WebSocket gateway at wss://api.stryke.gg/api/v2/stream (Stryke's engine), attached to the same HTTP server as the REST API. PAID-TIER-ONLY (the stream gatekeeper in the API layer): a paid Trading-API key (rt_ on tier builder/scale/enterprise) or an operator-granted Data-API key is required; the free/self-registered tier and the public builder key are DENIED at the WS upgrade with 401. One shared reference-counted poller/feed per distinct topic fans live data to every subscriber, so backend load scales with distinct topics, not client count. Two channels: launches (new pools, optional dex filter — free) and token+mint (live per-mint trade tape — counts against the plan topic cap). Per-tier caps: builder 2 conns/2 topics, scale 20/15, enterprise 50/50, internal/data-grant 20/30 (all env-tunable). Global backstops: 500 concurrent connections, 300 distinct topics, 2048-byte messages, 30s heartbeat. All frames are JSON text: welcome / snapshot / update / pong / unsubscribed / error.",
   "icon": "bolt",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/stream",
     "name": "Realtime stream (WebSocket)",
     "summary": "WebSocket gateway at wss://api.stryke.gg/api/v2/stream (also reachable as /stream). Opened via an HTTP GET Upgrade — NOT a normal request/response. PAID-TIER-ONLY: authenticate with a paid Trading-API key (rt_… on tier builder/scale/enterprise) or an operator-granted Data-API key; the free/self-registered tier and the public builder key are DENIED at upgrade (401). Subscribe to two channels — launches (new pools, optional dex filter) and token+mint (live per-mint trade tape). One shared reference-counted poller/feed per distinct topic fans out to all subscribers, so backend load scales with distinct topics, not client count. Per-connection topic caps and per-key connection caps are enforced from the caller's tier.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "key",
       "type": "string",
       "required": false,
       "default": "",
       "description": "API key for auth when headers are unavailable (browsers). Precedence at upgrade: Authorization: Bearer <key> → ?key= → X-API-Key header → Sec-WebSocket-Protocol. A paid rt_ Trading-API key or an operator-granted Data-API key is required; the public builder key is always denied."
      }
     ],
     "bodyParams": [
      {
       "name": "op",
       "type": "string",
       "required": true,
       "description": "Client→server message op (JSON text frame): \"subscribe\" | \"unsubscribe\" | \"ping\". ping replies {type:\"pong\"} (app-level keepalive; separate from the 30s WS protocol ping/pong heartbeat)."
      },
      {
       "name": "channel",
       "type": "string",
       "required": true,
       "description": "Required for subscribe/unsubscribe: \"launches\" or \"token\"."
      },
      {
       "name": "dex",
       "type": "string",
       "required": false,
       "description": "launches channel only. Optional DEX filter; one of: pumpfun, pumpswap, raydium, raydium_cpmm, meteora, meteora_damm_v2, orca, bonk. Omit for all DEXes. Creates topic launches:<dex>."
      },
      {
       "name": "mint",
       "type": "string",
       "required": false,
       "description": "token channel only (REQUIRED there). Base58 SPL mint. Creates topic token:<mint> and counts against your plan's token-topic cap. launches subscriptions are free (do not count)."
      }
     ],
     "responseExample": "{\"_note\":\"Server→client text frames arrive in sequence (this field lists representative frames, not one object):\",\"on_connect\":{\"type\":\"welcome\",\"channels\":[\"launches\",\"token\"],\"heartbeatSec\":30,\"message\":\"Subscribe with {\\\"op\\\":\\\"subscribe\\\",\\\"channel\\\":\\\"launches\\\"} or {\\\"op\\\":\\\"subscribe\\\",\\\"channel\\\":\\\"token\\\",\\\"mint\\\":\\\"<mint>\\\"}\"},\"on_subscribe_snapshot\":{\"channel\":\"token:G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"type\":\"snapshot\",\"data\":[{\"mint\":\"G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"signature\":\"4xk9…Qm\",\"ts\":1783569412000,\"type\":\"buy\",\"priceUsd\":0.0000123,\"amountToken\":812344.5,\"amountQuote\":0.42,\"amountUsd\":32.6,\"trader\":\"B4sAAVYE9v5iadYXdNYUdD1LoqtJi7xELJiGvfLwkmJD\",\"platform\":\"pumpfun\"}]},\"on_new_trade_update\":{\"channel\":\"token:G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"type\":\"update\",\"data\":[{\"mint\":\"G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"signature\":\"9pQ…zR\",\"ts\":1783569418000,\"type\":\"sell\",\"priceUsd\":0.0000119,\"amountToken\":500000,\"amountQuote\":0.25,\"amountUsd\":18.9,\"trader\":\"HsBK95XGUpVD3ENufWapD3Jib6zodHE6xj3VLArNBX3v\",\"platform\":\"pumpfun\"}]},\"on_ping\":{\"type\":\"pong\"},\"on_unsubscribe\":{\"channel\":\"token:G6cHLEwYHyZJXBhKpSWMU6RUtcq9HM9UTn2cTEdKpump\",\"type\":\"unsubscribed\"},\"on_error\":{\"type\":\"error\",\"error\":\"token subscription limit (2) reached for your plan\"}}",
     "responseNotes": "Frame types: welcome (once, on connect — advertises channels + heartbeatSec) | snapshot (once per subscribe — the current window, up to 50 items) | update (deltas only — new items since last frame) | pong (reply to op:ping) | unsubscribed (ack) | error. launches item data is the raw indexed pool row (getRecentLaunches, ≤50, sorted by time). token trade object: { mint, signature, ts (epoch-ms), type (\"buy\"|\"sell\"), priceUsd, amountToken, amountQuote (SOL or USDC depending on the pair — same field on snapshot and update), amountUsd, trader, platform }. Trades dedup by signature; last 50 retained per topic. token topics are driven by a shared live trade WS when enabled, else a 3s poll; launches always polls the indexer every 3s. Protocol heartbeat: server pings every 30s and terminates sockets that miss a pong.",
     "errorCodes": [
      "401 Unauthorized (upgrade rejected: missing key, free/unknown tier, public builder key, or domain not in the key's allow-list)",
      "429 Too Many Connections (per-key connection cap for your tier reached)",
      "503 Service Unavailable (global connection ceiling reached — MAX_CONNS, default 500)",
      "error frame: \"server topic capacity reached, try again later\" (global distinct-topic ceiling, default 300)",
      "error frame: \"token subscription limit (<cap>) reached for your plan\" / \"subscription limit (25) reached\"",
      "error frame: \"invalid JSON\" | \"message too large\" (>2048 bytes) | \"op must be subscribe | unsubscribe | ping\" | \"unknown channel …\" | \"token channel requires a valid \\\"mint\\\"\""
     ],
     "notes": "PAID gate (the stream gatekeeper): (1) operator Data-API key with an explicit stream grant → allowed; the public builder key → always denied; (2) Trading-API key → allowed only on a paid tier (internal/unlimited keys map to the internal cap). Per-tier caps {connections, tokenTopics}: builder 2/2, scale 20/15, enterprise 50/50, internal/data grant 20/30 (all env-tunable). launches subscriptions are free and are NOT counted against the token-topic cap; only token:<mint> topics count. Message frames capped at 2048 bytes. Auth precedence: Authorization: Bearer → ?key= → X-API-Key → Sec-WebSocket-Protocol. Domain allow-list is checked against the browser Origin/Referer, not the API host. Copy-paste (browser/Node ws): const ws = new WebSocket(\"wss://api.stryke.gg/api/v2/stream?key=YOUR_PAID_KEY\"); ws.onopen = () => { ws.send(JSON.stringify({op:\"subscribe\",channel:\"launches\",dex:\"pumpfun\"})); ws.send(JSON.stringify({op:\"subscribe\",channel:\"token\",mint:\"So11111111111111111111111111111111111111112\"})); }; ws.onmessage = (e) => { const f = JSON.parse(e.data); if (f.type===\"update\") console.log(f.channel, f.data); }; setInterval(()=>ws.readyState===1&&ws.send(JSON.stringify({op:\"ping\"})), 25000);",
     "id": "get-api-v2-stream"
    }
   ]
  },
  {
   "group": "account",
   "label": "Account & Keys",
   "icon": "key",
   "groupSummary": "Issue and manage API keys via a Solana wallet: register, regenerate, the SIWS sign-in challenge/login, and the developer dashboard (usage, fee-wallet, allowed domains, white-label license).",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/register",
     "name": "Register for API Key",
     "summary": "Creates a new API key for a Solana wallet that has no existing active key. The plaintext key is returned exactly once and cannot be retrieved later (only a hash is stored). The fee_wallet defaults to the owner wallet. Rate-limited to 5 attempts per hour per IP.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddress",
       "type": "string",
       "required": true,
       "description": "The owner's Solana wallet address (base58). Validated by constructing a PublicKey; also used as the default fee wallet."
      },
      {
       "name": "email",
       "type": "string",
       "required": false,
       "description": "Contact email. If provided, must match a basic email regex or the request is rejected with 400."
      },
      {
       "name": "websiteUrl",
       "type": "string",
       "required": false,
       "description": "The integrator's website URL, stored on the key record. No format validation."
      }
     ],
     "responseExample": "{ \"success\": true, \"apiKey\": \"stryke_8f3c...e21a\", \"message\": \"API key created successfully. Save this key securely - it cannot be retrieved later.\", \"dashboard\": \"https://api.stryke.gg/dashboard?wallet=4Nd1mY...\", \"feeStructure\": { \"totalFee\": \"15% of recovered SOL\", \"yourShare\": \"75% of fee (11.25% of total recovered)\", \"platformShare\": \"25% of fee (3.75% of total recovered)\" }, \"nextSteps\": [ \"Save your API key securely - it will not be shown again\", \"Use the SDK to integrate the widget into your website\", \"Fees are automatically split when users recover SOL\" ]\n}",
     "responseNotes": "apiKey is the only time the plaintext key is exposed (server stores a hash via hashApiKey). dashboard is a convenience URL containing the wallet. feeStructure and nextSteps are static informational fields.",
     "errorCodes": [
      "400",
      "429",
      "500"
     ],
     "notes": "Public bootstrap endpoint (no X-API-Key). 400 if walletAddress missing, if it fails PublicKey validation ('Invalid Solana wallet address'), if email is malformed, or if the wallet already has an active key ('This wallet already has an active API key...'). 429 from registrationLimiter (max 5/hour/IP, 1-hour window). 500 on DB/insert failure.",
     "id": "post-api-register"
    },
    {
     "method": "POST",
     "path": "/api/auth/challenge",
     "name": "Request Wallet Signature Challenge",
     "summary": "Step 1 of wallet ownership verification. Generates a unique nonce + human-readable message that the wallet must sign off-chain. The challenge is stored server-side keyed by wallet address and expires after 5 minutes. Used as the prerequisite for /api/regenerate-key and /api/auth/wallet-login.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddress",
       "type": "string",
       "required": true,
       "description": "The Solana wallet address (base58) to issue a challenge for. Validated via PublicKey construction."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Stryke API Authentication\\n\\nWallet: 4Nd1mY...\\nNonce: 9a2f...c1\\nTimestamp: 1718323200000\\n\\nSign this message to verify wallet ownership. This signature will not trigger any blockchain transaction or cost any fees.\", \"nonce\": \"9a2f4b...c1\", \"expiresIn\": 300\n}",
     "responseNotes": "The client must sign the exact `message` string (UTF-8) with the wallet's private key and submit the base58 signature to /api/regenerate-key or /api/auth/wallet-login. nonce is a 32-byte hex value. expiresIn is 300 seconds. Only one pending challenge per wallet is stored (a new request overwrites the previous one).",
     "errorCodes": [
      "400"
     ],
     "notes": "Public bootstrap endpoint (no X-API-Key). 400 if walletAddress is missing ('Wallet address is required') or fails PublicKey validation ('Invalid Solana wallet address'). Challenges are held in an in-memory Map and swept every 60s, deleting any older than 5 minutes. No DB access, so no 500 path.",
     "id": "post-api-auth-challenge"
    },
    {
     "method": "POST",
     "path": "/api/auth/wallet-login",
     "name": "Wallet Login (Session Token)",
     "summary": "Verifies a signature against the wallet's pending challenge and, if a matching active API key exists, issues a 1-hour dashboard session token. The session token authorizes /api/dashboard-session and /api/update-domains without exposing the API key. Returns key metadata and stats but never the plaintext API key.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddress",
       "type": "string",
       "required": true,
       "description": "The wallet address that requested the challenge. Validated via PublicKey."
      },
      {
       "name": "signature",
       "type": "string",
       "required": true,
       "description": "Base58-encoded ed25519 signature of the challenge `message`, verified with tweetnacl sign.detached.verify against the wallet's public key."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Wallet verified successfully\", \"sessionToken\": \"c4e7...f0\", \"expiresIn\": 3600, \"apiKey\": { \"id\": 42, \"feeWallet\": \"4Nd1mY...\", \"feeWalletBalance\": 12500000, \"allowedDomains\": [\"example.com\", \"*.example.com\"], \"isActive\": true, \"hasBrandingLicense\": false, \"stats\": { \"totalRequests\": 1840, \"totalAccountsClosed\": 312, \"totalSolRecovered\": 5.421, \"totalFeesEarned\": 0.813 }, \"createdAt\": \"2026-01-04T10:22:11.000Z\", \"lastUsedAt\": \"2026-06-13T08:01:55.000Z\" }\n}",
     "responseNotes": "sessionToken is a 32-byte hex value valid for 3600s (stored in an in-memory walletSessions Map, swept every 10 min). feeWalletBalance is in lamports via the node RPC and may be null if the balance lookup fails (non-fatal). allowedDomains is null when the key is unrestricted. On success the consumed challenge is deleted.",
     "errorCodes": [
      "400",
      "401",
      "404",
      "500"
     ],
     "notes": "Public bootstrap endpoint (no X-API-Key). 400 if walletAddress/signature missing, wallet fails validation, no pending challenge exists, the challenge expired (>5 min, also deletes it), or signature decoding throws. 401 if the signature is cryptographically invalid ('Invalid signature...'). 404 if the wallet has no active key — response includes needsRegistration: true. 500 on DB failure. Note: code references walletSessions before its `const` declaration (later in the file) — relies on hoisting at call time.",
     "id": "post-api-auth-wallet-login"
    },
    {
     "method": "POST",
     "path": "/api/regenerate-key",
     "name": "Regenerate API Key",
     "summary": "Step 2 (key rotation): verifies the wallet signature against its pending challenge, then issues a brand-new API key for the wallet's existing active key record, invalidating the old key. The new plaintext key is returned exactly once. Rate-limited to 5 attempts/hour/IP.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddress",
       "type": "string",
       "required": true,
       "description": "The owner wallet that requested the challenge. Validated via PublicKey."
      },
      {
       "name": "signature",
       "type": "string",
       "required": true,
       "description": "Base58-encoded ed25519 signature of the challenge `message`, verified with tweetnacl against the wallet pubkey."
      }
     ],
     "responseExample": "{ \"success\": true, \"apiKey\": \"stryke_2b9d...77fa\", \"message\": \"API key regenerated successfully. Your old key is now invalid. Save this new key securely!\", \"warning\": \"Your previous API key has been invalidated. Update all integrations with this new key.\"\n}",
     "responseNotes": "apiKey is the new plaintext key, shown only once (DB stores its hash, overwriting the prior api_key_hash on the same row id — preserving stats and settings). The previously issued key stops authenticating immediately.",
     "errorCodes": [
      "400",
      "401",
      "404",
      "429",
      "500"
     ],
     "notes": "Public bootstrap endpoint (no X-API-Key) but signature-gated. 400 if walletAddress/signature missing, wallet invalid, no pending challenge, challenge expired, or signature decode error. 401 if signature invalid. 404 if no active key exists for the wallet ('Please register first.'). 429 from registrationLimiter (5/hour/IP). 500 on DB failure. Consumes (deletes) the challenge on successful verification.",
     "id": "post-api-regenerate-key"
    },
    {
     "method": "GET",
     "path": "/api/dashboard/{wallet}",
     "name": "Get Public Dashboard by Wallet",
     "summary": "Returns all API key records (active and inactive) owned by a wallet, with per-key stats and metadata. Public and unauthenticated — no signature or API key required — so it exposes only non-sensitive fields (no plaintext key, no session). Intended for a basic dashboard lookup before login.",
     "authRequired": false,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "The owner's Solana wallet address (base58). Validated via PublicKey; invalid input returns 400."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"hasApiKey\": true, \"apiKeys\": [ { \"id\": 42, \"feeWallet\": \"4Nd1mY...\", \"isActive\": true, \"hasBrandingLicense\": false, \"brandingLicenseTx\": null, \"stats\": { \"totalRequests\": 1840, \"totalAccountsClosed\": 312, \"totalSolRecovered\": 5.421, \"totalFeesEarned\": 0.813 }, \"email\": \"dev@example.com\", \"websiteUrl\": \"https://example.com\", \"createdAt\": \"2026-01-04T10:22:11.000Z\", \"lastUsedAt\": \"2026-06-13T08:01:55.000Z\" } ]\n}",
     "responseNotes": "When the wallet owns no keys, returns 200 with hasApiKey: false and a message (no apiKeys array). Returns ALL keys for the wallet ordered by created_at DESC, including inactive ones (isActive reflects per-key status). Does not include allowedDomains or any session/key secret. brandingLicenseTx is the on-chain signature once a license is active, else null.",
     "errorCodes": [
      "400",
      "500"
     ],
     "notes": "Public, no auth despite the path comment mentioning signature. 400 if the wallet param fails PublicKey validation ('Invalid wallet address'). 500 on DB failure. The absence-of-key case is a 200 (success: true), not a 404.",
     "id": "get-api-dashboard-wallet"
    },
    {
     "method": "GET",
     "path": "/api/dashboard-session",
     "name": "Get Dashboard via Session Token",
     "summary": "Returns the authenticated dashboard for the key tied to a valid wallet-login session token, including key metadata, stats, fee wallet balance, and the 20 most recent recovery transactions. Authenticated by the X-Session-Token header (issued by /api/auth/wallet-login), not by X-API-Key.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"walletAddress\": \"4Nd1mY...\", \"apiKey\": { \"id\": 42, \"feeWallet\": \"4Nd1mY...\", \"feeWalletBalance\": 12500000, \"allowedDomains\": [\"example.com\"], \"isActive\": true, \"hasBrandingLicense\": false, \"stats\": { \"totalRequests\": 1840, \"totalAccountsClosed\": 312, \"totalSolRecovered\": 5.421, \"totalFeesEarned\": 0.813 }, \"createdAt\": \"2026-01-04T10:22:11.000Z\", \"lastUsedAt\": \"2026-06-13T08:01:55.000Z\" }, \"recentTransactions\": [ { \"walletAddress\": \"9xQe...\", \"accountsClosed\": 4, \"solRecovered\": 0.0082, \"feeEarned\": 0.00092, \"signature\": \"5h2k...\", \"createdAt\": \"2026-06-12T19:44:02.000Z\" } ]\n}",
     "responseNotes": "recentTransactions are the latest 20 rows from sk_api_transactions for this key, newest first. feeWalletBalance is in lamports via the node RPC, null if the lookup fails. allowedDomains is null when unrestricted. This is the session-authenticated counterpart to /api/dashboard/{wallet} and returns richer (private) data.",
     "errorCodes": [
      "401",
      "404",
      "500"
     ],
     "notes": "Auth is via the X-Session-Token request header (NOT X-API-Key). 401 if the header is missing ('Session token required'), the token is unknown ('Invalid or expired session...'), or the stored session is past expiresAt (also deletes it, 'Session expired...'). 404 if the key referenced by the session no longer exists. 500 on DB failure. Sessions are in-memory (lost on server restart).",
     "id": "get-api-dashboard-session"
    },
    {
     "method": "POST",
     "path": "/api/update-domains",
     "name": "Update Allowed Domains (Session)",
     "summary": "Sets the allowed-domain allowlist for the session's API key. Authenticated by the X-Session-Token header. Domains are normalized (trimmed, lowercased) and validated against a hostname regex (supports a leading wildcard like *.example.com); an empty/invalid list clears the restriction so the key works on any domain.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "domains",
       "type": "array<string>",
       "required": false,
       "description": "Array of domain strings to allow (e.g. ['example.com','*.app.example.com']). Each is trimmed, lowercased, and filtered by a hostname regex permitting an optional leading '*.'. If the array is empty, missing, or all entries are invalid, the domain restriction is removed (key allowed on all domains)."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Domains updated: example.com,*.app.example.com\", \"allowedDomains\": [\"example.com\", \"*.app.example.com\"]\n}",
     "responseNotes": "allowedDomains echoes the stored, normalized list, or null when restrictions were removed (in which case message is 'Domain restrictions removed'). The value is persisted as a comma-separated string in sk_api_keys.allowed_domains and enforced later by validateApiKey/isDomainAllowed.",
     "errorCodes": [
      "401",
      "500"
     ],
     "notes": "Auth via X-Session-Token header (NOT X-API-Key). 401 if the token is missing, invalid/unknown, or expired (expired tokens are deleted). No 400 path — invalid domain entries are silently filtered out rather than rejected. 500 on DB failure.",
     "id": "post-api-update-domains"
    },
    {
     "method": "POST",
     "path": "/api/dashboard/update-fee-wallet",
     "name": "Update Fee Wallet",
     "summary": "Changes the destination (fee) wallet that receives the integrator's share of recovery fees for a specific API key. Authorization is by ownership match: the UPDATE only succeeds when the supplied apiKeyId belongs to ownerWallet. No signature or session token is verified.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "ownerWallet",
       "type": "string",
       "required": true,
       "description": "The wallet that owns the API key. Validated via PublicKey and matched against the key's owner_wallet in the WHERE clause."
      },
      {
       "name": "newFeeWallet",
       "type": "string",
       "required": true,
       "description": "The new Solana wallet (base58) to receive fee payouts. Validated via PublicKey."
      },
      {
       "name": "apiKeyId",
       "type": "number",
       "required": true,
       "description": "The numeric id of the API key to update. Must belong to ownerWallet or the update affects 0 rows (404)."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Fee wallet updated successfully\"\n}",
     "responseNotes": "Returns only success + message. The UPDATE is scoped by both id and owner_wallet, so a mismatch returns 404 rather than modifying another owner's key.",
     "errorCodes": [
      "400",
      "404",
      "500"
     ],
     "notes": "Despite the source comment ('requires wallet signature verification'), the handler verifies NO signature and requires no X-API-Key or session token — authorization is purely the ownerWallet/apiKeyId pair matching a DB row. 400 if any of ownerWallet/newFeeWallet/apiKeyId is missing ('Missing required fields') or if either wallet fails PublicKey validation ('Invalid wallet address'). 404 if no row matches (affectedRows === 0, 'API key not found or unauthorized'). 500 on DB failure.",
     "id": "post-api-dashboard-update-fee-wallet"
    },
    {
     "method": "GET",
     "path": "/api/branding-license",
     "name": "Get Branding License Info",
     "summary": "Returns static information about the white-label branding license: its cost in SOL, the payment recipient wallet, and the list of unlocked features. Public, no parameters, no DB access — purely informational for the purchase flow.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"cost\": 0.5, \"paymentWallet\": \"REfUndEd1DoYqRzHtjPpx1WYgTL2f2C86pnU44x1jgc\", \"features\": [ \"Use your own logo in the widget\", \"Remove \\\"Powered by Stryke\\\" branding\", \"Custom redirect URLs\", \"White-label experience for your users\" ]\n}",
     "responseNotes": "cost is BRANDING_LICENSE_COST (0.5 SOL) and paymentWallet is BRANDING_LICENSE_WALLET — the values to use when constructing the payment that /api/verify-branding-payment will later validate. Fully static; identical on every call.",
     "errorCodes": [],
     "notes": "Public, no auth, synchronous, no DB. The handler is not async and has no try/catch, so it returns 200 in all normal cases (no documented error responses).",
     "id": "get-api-branding-license"
    },
    {
     "method": "POST",
     "path": "/api/verify-branding-payment",
     "name": "Verify Branding License Payment",
     "summary": "Validates an on-chain SOL payment for the branding license and, if valid, activates the white-label license on the specified API key. Confirms the transaction on-chain, checks it is recent (<1 hour), and verifies the license wallet received at least ~0.5 SOL (1% fee tolerance). Authorization is by ownerWallet/apiKeyId ownership match.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "ownerWallet",
       "type": "string",
       "required": true,
       "description": "The wallet that owns the API key. Matched against owner_wallet for the given apiKeyId."
      },
      {
       "name": "transactionSignature",
       "type": "string",
       "required": true,
       "description": "The Solana transaction signature of the 0.5 SOL payment to the branding license wallet. Fetched on-chain (commitment 'confirmed') and inspected for the payment to BRANDING_LICENSE_WALLET."
      },
      {
       "name": "apiKeyId",
       "type": "number",
       "required": true,
       "description": "The numeric id of the API key to activate branding on. Must belong to ownerWallet."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Branding license activated! You can now use custom logos and remove \\\"Powered by Stryke\\\".\", \"features\": [ \"Use your own logo in the widget\", \"Remove \\\"Powered by Stryke\\\" branding\", \"Custom redirect URLs\", \"White-label experience for your users\" ]\n}",
     "responseNotes": "On success, sets has_branding_license = TRUE and stores the signature in branding_license_tx. If the license was already active, returns 200 with alreadyActive: true and message 'Branding license already active' (no re-verification). Payment is accepted if the license wallet's balance delta >= 0.99 * 0.5 SOL (in lamports).",
     "errorCodes": [
      "400",
      "404",
      "500"
     ],
     "notes": "Public, no X-API-Key/session/signature — authorization is the ownerWallet/apiKeyId ownership match. 404 if no key matches id+owner_wallet ('API key not found or unauthorized'). 400 if any required field is missing ('Missing required fields'), the transaction is not found/unconfirmed, it is older than 1 hour ('Transaction is too old...'), or the payment amount/recipient cannot be verified ('Payment verification failed. Please send 0.5 SOL to REfUndEd1...'). 500 on DB/RPC failure. Recency check uses txInfo.blockTime.",
     "id": "post-api-verify-branding-payment"
    }
   ]
  },
  {
   "group": "recovery",
   "label": "Recovery (v1)",
   "icon": "shield",
   "groupSummary": "The v1 token-account recovery API: scan a wallet for closeable/rent-bearing SPL accounts, build a close transaction, confirm it, plus wallet analysis, token info, and aggregate stats.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/v1/check",
     "name": "Check Wallets for Recoverable Rent",
     "summary": "Scans one or more Solana wallets for empty and balance-holding token accounts and returns the estimated SOL rent that could be recovered by closing/burning them, without producing or submitting any transactions.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddresses",
       "type": "string[]",
       "required": true,
       "description": "Array of base58 Solana wallet addresses to scan. Must be a non-empty array with at most 20 entries. Invalid addresses are returned per-wallet with an `error` field rather than failing the whole request."
      },
      {
       "name": "includeAccounts",
       "type": "boolean",
       "required": false,
       "description": "When true, each wallet result also includes an `accounts` object with `empty` and `burnable` arrays of formatted token-account details (account, mint, displayName, owner, program, decimals, uiAmount, amountRaw, state, isFrozen, type). Defaults to false."
      }
     ],
     "responseExample": "{ \"success\": true, \"wallets\": [ { \"address\": \"4Nd1mB...x9Qe\", \"emptyTokenAccounts\": 12, \"accountsWithBalance\": 3, \"walletBalance\": 0.0421, \"potentialRecovery\": 0.02446, \"estimatedReturn\": 0.02201, \"fee\": 0.00245, \"burnRecovery\": 0.006116, \"burnEstimatedReturn\": 0.005504, \"burnFee\": 0.000612, \"totalRecovery\": 0.030576, \"totalEstimatedReturn\": 0.027518, \"totalFee\": 0.003058, \"canProcess\": true } ], \"totals\": { \"totalEmptyAccounts\": 12, \"totalAccountsWithBalance\": 3, \"totalCloseRecovery\": 0.02446, \"totalCloseReturn\": 0.02201, \"totalBurnRecovery\": 0.006116, \"totalBurnReturn\": 0.005504, \"totalPotentialRecovery\": 0.030576, \"totalEstimatedReturn\": 0.027518 }, \"hasBrandingLicense\": false\n}",
     "responseNotes": "All SOL amounts are in SOL (lamports / LAMPORTS_PER_SOL), not lamports. `potentialRecovery`/`estimatedReturn`/`fee` are the close-only (empty-account) figures kept for backwards compatibility; `burn*` are the additional recovery from burning non-empty accounts; `total*` is close+burn combined. `canProcess` is true when the wallet has > 5000 lamports (enough for a tx fee) or has no empty accounts. Per-wallet entries that fail validation/fetch are shaped `{address, error: 'Invalid wallet address or fetch error'}` and are excluded from `totals`. `hasBrandingLicense` reflects the calling API key's branding-license flag (used by the SDK to toggle the upgrade banner).",
     "errorCodes": [
      "400 — walletAddresses array required (missing/empty/not an array)",
      "400 — Maximum 20 wallets per request",
      "401 — API key required / Invalid or inactive API key",
      "403 — Domain not allowed (key domain restriction)",
      "429 — Rate limited (apiLimiter)",
      "500 — Failed to check wallets"
     ],
     "notes": "Middleware order: apiLimiter -> validateApiKey. Read-only RPC scan via getParsedTokenAccountsByOwner for both the legacy SPL Token program and Token-2022. Recovery math uses server constants TOKEN_ACCOUNT_RENT and TOTAL_FEE_RATE; no transaction is built or submitted here (see /api/v1/close for that).",
     "id": "post-api-v1-check"
    },
    {
     "method": "POST",
     "path": "/api/v1/close",
     "name": "Build Close/Burn Transactions",
     "summary": "Builds unsigned (base64-serialized) Solana transactions that close empty token accounts (and optionally burn + close balance-holding accounts) to reclaim rent, chunked to fit transaction size limits. The client signs and submits them; nothing is broadcast server-side.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddresses",
       "type": "string[]",
       "required": true,
       "description": "Array of base58 wallet addresses (the account owners) to build transactions for. Non-empty, max 20 entries."
      },
      {
       "name": "refundWallet",
       "type": "string",
       "required": false,
       "description": "Optional base58 address that should receive the reclaimed rent (close-account destination). Defaults to each wallet's own address when omitted."
      },
      {
       "name": "burnAccounts",
       "type": "boolean",
       "required": false,
       "description": "When true, also burns tokens in non-empty accounts (then closes them) to reclaim their rent. Uses a smaller chunk size and higher compute budget per account. Defaults to false."
      },
      {
       "name": "selectedTokens",
       "type": "string[]",
       "required": false,
       "description": "Optional whitelist of mint addresses to burn. Only applied when burnAccounts is true; when non-empty, only accounts whose mint is in this set are burned. Defaults to [] (burn all non-empty accounts)."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Generated transactions for 1 wallets\", \"walletTransactions\": { \"4Nd1mB...x9Qe\": [ { \"transaction\": \"AQAAAAA...base64...\", \"accountsInTx\": 8, \"estimatedFee\": 0.00163, \"usedFeePayer\": false } ] }, \"summary\": { \"totalAccountsToClose\": 8, \"totalPotentialRecovery\": 0.0163, \"totalFee\": 0.00163, \"platformFee\": 0.001141, \"ownerFee\": 0.000489, \"userReturn\": 0.01467 }, \"instructions\": { \"step1\": \"Decode each transaction from base64\", \"step2\": \"Sign with the wallet owner's keypair\", \"step3\": \"Submit to Solana network\", \"step4\": \"Call /api/v1/confirm to log the transaction\" }\n}",
     "responseNotes": "`walletTransactions` is keyed by wallet address; each value is normally an array of `{transaction (base64), accountsInTx, estimatedFee (SOL), usedFeePayer}`. Wallets with no closeable/burnable accounts are skipped (omitted from the map). If a single wallet errors while building, its value becomes `{error: <message>}` instead of an array (the overall response is still 200/success:true). Transactions are serialized with requireAllSignatures:false; when the owner balance is low (< 0.002 SOL) and a fee payer is configured, the transaction is partially signed by the platform fee payer and `usedFeePayer` is true. Fees (platform + owner split) are embedded as SystemProgram.transfer instructions; `summary` figures are in SOL.",
     "errorCodes": [
      "400 — walletAddresses array required (missing/empty/not an array)",
      "400 — Maximum 20 wallets per request",
      "401 — API key required / Invalid or inactive API key",
      "403 — Domain not allowed (key domain restriction)",
      "429 — Rate limited (apiLimiter)",
      "500 — Failed to generate transactions"
     ],
     "notes": "Middleware order: apiLimiter -> validateApiKey. Returns UNSIGNED transactions only — it never broadcasts. Frozen accounts are skipped. Burn mode uses the optional 'safe burn' path: the burn wallet pre-creates its own ATAs (prepareSafeBurnAccounts) and tokens are transferred there before close; falls back to a direct on-account burn if no ATA is available. Platform-fee and owner-fee transfers are only added when the respective fee wallets are configured (and the owner fee wallet already exists on-chain with non-zero balance). After signing/submitting, clients should call /api/v1/confirm to log stats. Per-wallet build failures return {error:<message>} for that wallet (partial success); frozen accounts are silently skipped (accountsInTx can be lower than the closeable count); platform/owner fee transfers are omitted when the respective fee wallet is absent or zero-balance.",
     "id": "post-api-v1-close"
    },
    {
     "method": "POST",
     "path": "/api/v1/confirm",
     "name": "Confirm Transaction (Log Stats)",
     "summary": "Records a completed close/burn transaction for tracking and per-API-key statistics (accounts closed, SOL recovered, owner fee earned). Does not verify the transaction on-chain.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "walletAddress",
       "type": "string",
       "required": true,
       "description": "The wallet address the transaction was executed for."
      },
      {
       "name": "signature",
       "type": "string",
       "required": true,
       "description": "The on-chain transaction signature to log (stored as transaction_signature)."
      },
      {
       "name": "accountsClosed",
       "type": "number",
       "required": false,
       "description": "Number of token accounts closed/burned in the transaction. Defaults to 0 when omitted; added to the key's running total."
      },
      {
       "name": "solRecovered",
       "type": "number",
       "required": false,
       "description": "SOL reclaimed by the transaction. Defaults to 0; drives the platform-fee / owner-fee split and the key's running totals."
      }
     ],
     "responseExample": "{ \"success\": true, \"message\": \"Transaction logged successfully\", \"stats\": { \"accountsClosed\": 8, \"solRecovered\": 0.0163, \"ownerFeeEarned\": 0.000489 }\n}",
     "responseNotes": "On success the handler inserts a row into sk_api_transactions and increments sk_api_keys totals (total_accounts_closed, total_sol_recovered, total_fees_earned with the owner-fee share). `stats.accountsClosed`/`solRecovered` echo the request body verbatim; `ownerFeeEarned` is computed as solRecovered * TOTAL_FEE_RATE * (1 - a platform setting). For the builder/preview key (apiKeyData.is_builder_key) the handler short-circuits: it skips all DB writes and returns success with a different message ('Builder preview transaction acknowledged (not persisted)') and stats defaulted to 0 where unset.",
     "errorCodes": [
      "400 — walletAddress and signature required",
      "401 — API key required / Invalid or inactive API key",
      "403 — Domain not allowed (key domain restriction)",
      "429 — Rate limited (apiLimiter)",
      "500 — Failed to log transaction"
     ],
     "notes": "Middleware order: apiLimiter -> validateApiKey. Purely an accounting/telemetry endpoint — it trusts the caller-supplied accountsClosed/solRecovered and does not re-verify the signature against the chain. Builder preview key bypasses persistence to avoid foreign-key issues in the SDK demo flow.",
     "id": "post-api-v1-confirm"
    },
    {
     "method": "GET",
     "path": "/api/v1/stats",
     "name": "Get API Key Stats",
     "summary": "Returns lifetime usage statistics for the calling API key plus its 10 most recent logged transactions.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{ \"success\": true, \"stats\": { \"totalRequests\": 1042, \"totalAccountsClosed\": 318, \"totalSolRecovered\": 0.6471, \"totalFeesEarned\": 0.0194, \"feeWallet\": \"9xQeWv...Hh3\", \"createdAt\": \"2026-01-04T11:22:00.000Z\", \"lastUsedAt\": \"2026-06-13T09:15:42.000Z\" }, \"recentTransactions\": [ { \"walletAddress\": \"4Nd1mB...x9Qe\", \"accountsClosed\": 8, \"solRecovered\": 0.0163, \"feeEarned\": 0.000489, \"signature\": \"5h2k...Qe\", \"createdAt\": \"2026-06-12T18:40:00.000Z\" } ]\n}",
     "responseNotes": "`stats` is read directly off the authenticated key row (sk_api_keys); totalSolRecovered/totalFeesEarned are parseFloat'd from DECIMAL columns. `recentTransactions` is the 10 newest rows from sk_api_transactions for this key (ordered created_at DESC), mapped to {walletAddress, accountsClosed, solRecovered, feeEarned (= owner_fee), signature, createdAt}. All stats are scoped to the calling key only.",
     "errorCodes": [
      "401 — API key required / Invalid or inactive API key",
      "403 — Domain not allowed (key domain restriction)",
      "500 — Failed to get stats"
     ],
     "notes": "Auth via validateApiKey only — NOTE: unlike the other v1 endpoints this route is NOT wrapped by apiLimiter, so it is not subject to the apiLimiter rate limit (and thus does not return apiLimiter 429s).",
     "id": "get-api-v1-stats"
    },
    {
     "method": "POST",
     "path": "/api/token-info",
     "name": "Token Metadata (Proxy)",
     "summary": "Fetches token metadata for a batch of mint addresses by proxying to the public Stryke.com token-info service. Pass-through endpoint with permissive CORS.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mints",
       "type": "string[]",
       "required": true,
       "description": "Array of base58 token mint addresses to look up metadata for. Must be a non-empty array; otherwise 400."
      }
     ],
     "responseExample": "{ /* verbatim JSON body proxied from https://Stryke.com/api/token-info, returned with the upstream HTTP status. Typically a map/array of token metadata keyed by mint. */ }",
     "responseNotes": "This is a thin proxy: it forwards {mints} to https://Stryke.com/api/token-info and relays the upstream response. If the upstream body is valid JSON it is returned with res.status(upstream.status).json(...); if the upstream body is not JSON, the raw text is relayed via res.send(text) with the upstream status. The exact shape is whatever the upstream service returns, so it is not fixed by this handler.",
     "errorCodes": [
      "400 — Missing or invalid 'mints' array (not an array or empty)",
      "4xx/5xx — relayed verbatim from the upstream Stryke.com response (status + body passed through)",
      "502 — Unable to fetch token metadata right now (the upstream fetch threw / network failure)"
     ],
     "notes": "NOT protected by validateApiKey — this is a public proxy. It only sets CORS headers (Access-Control-Allow-Origin echoes the request Origin or '*'; Methods POST, OPTIONS; Headers Content-Type) and validates the mints array before forwarding. No rate limiter (apiLimiter) is attached. Defined just above the 'PUBLIC API ENDPOINTS (require API key)' section, before the v1 recovery routes.",
     "id": "post-api-token-info"
    }
   ]
  },
  {
   "group": "rpc-direct",
   "label": "Direct RPC",
   "icon": "bolt",
   "groupSummary": "Solana RPC passthrough: send a signed transaction, check its status, and simulate before sending.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/api/rpc/send",
     "name": "Send Transaction (RPC proxy)",
     "summary": "Server-side proxy that forwards a signed, base64-encoded transaction to the the node RPC `sendTransaction` method and returns the raw JSON-RPC response. Exists so the paid Stryke's node layer key never leaves the server; defaults skipPreflight=false and preflightCommitment=confirmed, which the caller can override via `options`.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "transaction",
       "type": "string",
       "required": true,
       "description": "Base64-encoded, fully-signed serialized transaction. Required — a falsy value returns 400."
      },
      {
       "name": "options",
       "type": "object",
       "required": false,
       "description": "Optional overrides spread onto the Stryke's node layer sendTransaction config object. Defaults applied before spread: { encoding: 'base64', skipPreflight: false, preflightCommitment: 'confirmed' }. Anything here (e.g. skipPreflight, maxRetries, preflightCommitment) overrides those defaults. Defaults to {}."
      }
     ],
     "responseExample": "{ \"jsonrpc\": \"2.0\", \"id\": \"6f9619ff-8b86-d011-b42d-00cf4fc964ff\", \"result\": \"5V_signature_base58_...\"\n}",
     "responseNotes": "The handler passes through the raw Stryke's node layer JSON-RPC envelope verbatim (`res.status(status).json(data)`), so the body is whatever Stryke's node layer returns and the HTTP status is the upstream status (typically 200). On success the envelope has `result` = the transaction signature string. If Stryke's node layer rejects the tx, the same envelope carries an `error` object ({ code, message, data }) instead of `result`, still under the upstream HTTP status. The encoding/commitment defaults are not echoed back; only the JSON-RPC envelope is returned.",
     "errorCodes": [
      "400 - Missing transaction (base64): { \"error\": \"Missing transaction (base64)\" } when req.body.transaction is falsy.",
      "502 - Upstream fetch threw (network/Stryke's node layer unreachable): { \"error\": \"the node RPC send failed\" }. Note: a node-level JSON-RPC error does NOT produce 502 — it is passed through with the upstream HTTP status (usually 200) as an `error` field in the body."
     ],
     "notes": "PUBLIC endpoint — no X-API-Key and no rate limiter. The app.post signature is (req, res) only; the global app.use chain (line 91-92) mounts just express.json({limit:'5mb'}) and compression, and neither validateApiKey nor apiLimiter is attached. Intent (per the section banner at the API layer:2244) is to protect the paid Stryke's node layer key by keeping RPC calls server-side. Upstream call: Stryke's node layerRpcRequest('sendTransaction', [transaction, config]) at the API layer:241, which POSTs a JSON-RPC 2.0 body (id = uuidv4()) to the platform config. Body parsing tolerates a missing body via `req.body || {}`. Max request body 5mb (express.json limit). ABUSE SURFACE: no auth and no rate limit on this proxy to a paid upstream RPC key — any caller can submit unlimited transactions (cost-amplification / spam). A method-level RPC error returns HTTP 200 with a JSON-RPC {error} body; request body cap 5MB.",
     "id": "post-api-rpc-send"
    },
    {
     "method": "POST",
     "path": "/api/rpc/status",
     "name": "Get Signature Statuses (RPC proxy)",
     "summary": "Server-side proxy to the node RPC `getSignatureStatuses`. Accepts either a single `signature` or an array of `signatures`, normalizes to an array, and returns the raw JSON-RPC response. Defaults searchTransactionHistory=false (overridable via `options`).",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "signatures",
       "type": "string[]",
       "required": false,
       "description": "Array of base58 transaction signatures to look up. Takes precedence over `signature`. Either this or `signature` must be supplied and resolve to a non-empty array, or the request 400s."
      },
      {
       "name": "signature",
       "type": "string",
       "required": false,
       "description": "A single base58 transaction signature; wrapped into a one-element array if `signatures` is not provided. Either this or `signatures` is required."
      },
      {
       "name": "options",
       "type": "object",
       "required": false,
       "description": "Optional overrides spread onto the getSignatureStatuses config. Default applied before spread: { searchTransactionHistory: false }. Set { searchTransactionHistory: true } to search older confirmed transactions. Defaults to {}."
      }
     ],
     "responseExample": "{ \"jsonrpc\": \"2.0\", \"id\": \"6f9619ff-8b86-d011-b42d-00cf4fc964ff\", \"result\": { \"context\": { \"slot\": 282934711 }, \"value\": [ { \"slot\": 282934700, \"confirmations\": 10, \"err\": null, \"confirmationStatus\": \"confirmed\" }, null ] }\n}",
     "responseNotes": "Raw Stryke's node layer JSON-RPC envelope passed through verbatim with the upstream HTTP status (typically 200). `result.value` is an array aligned 1:1 with the input signatures; each element is either a status object ({ slot, confirmations, err, confirmationStatus, status }) or null when the signature is unknown/expired from the recent cache (and searchTransactionHistory was false). On a node-level failure the envelope carries an `error` object instead of `result`.",
     "errorCodes": [
      "400 - Missing signature(s): { \"error\": \"Missing signature(s)\" } when neither `signatures` nor `signature` yields a non-empty array (covers null, non-array, and empty-array inputs).",
      "502 - Upstream fetch threw: { \"error\": \"the node RPC status failed\" }. A Stryke's node layer JSON-RPC error is NOT a 502 — it passes through with the upstream HTTP status."
     ],
     "notes": "PUBLIC endpoint — no X-API-Key, no rate limiter (app.post signature is (req, res) only). Input normalization: const sigArray = signatures || (signature ? [signature] : null); validated with `!sigArray || !Array.isArray(sigArray) || !sigArray.length`. Upstream: Stryke's node layerRpcRequest('getSignatureStatuses', [sigArray, { searchTransactionHistory: false,...options }]). Part of the RPC-proxy block (the API layer:2244) that shields the paid Stryke's node layer key. No auth / no rate limit (paid-RPC proxy); a method-level RPC error returns HTTP 200 with a JSON-RPC {error} body — inspect the body, not the status.",
     "id": "post-api-rpc-status"
    },
    {
     "method": "POST",
     "path": "/api/rpc/simulate",
     "name": "Simulate Transaction (debug)",
     "summary": "Debug endpoint that deserializes a base64 transaction (tries VersionedTransaction, falls back to legacy Transaction) and simulates it against the live connection with sigVerify=false and replaceRecentBlockhash=true. Returns a custom summary of the simulation rather than a raw RPC envelope.",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "transaction",
       "type": "string",
       "required": true,
       "description": "Base64-encoded serialized transaction (signed or unsigned — signatures are not verified during simulation). Required; falsy returns 400. Parsed as a VersionedTransaction first, then legacy Transaction.from() as fallback."
      },
      {
       "name": "options",
       "type": "object",
       "required": false,
       "description": "Optional overrides spread onto connection.simulateTransaction config. Defaults applied before spread: { sigVerify: false, replaceRecentBlockhash: true }. Can override these or add fields like { accounts: { addresses: [...] }, commitment }. Defaults to {}."
      }
     ],
     "responseExample": "{ \"success\": true, \"err\": null, \"logs\": [\"Program 11111111111111111111111111111111 invoke [1]\", \"Program 11111111111111111111111111111111 success\"], \"unitsConsumed\": 450, \"accountsReturned\": 0, \"simulationDetails\": { \"hasError\": false, \"computeUnits\": 450, \"logCount\": 2 }\n}",
     "responseNotes": "Unlike /send and /status, this returns a custom-shaped 200 body (not the raw JSON-RPC envelope) built from simulation.value: `success` (always true when simulation completes, even if the tx itself errored), `err` (the on-chain simulation error or null), `logs` (full program-log array, [] if none), `unitsConsumed` (compute units), `accountsReturned` (count of returned accounts, 0 if none), and `simulationDetails` { hasError: !!err, computeUnits, logCount }. A transaction that fails on-chain still yields HTTP 200 with success:true and a populated `err` — inspect `err`/`simulationDetails.hasError`, not the HTTP status, to know if the tx would fail.",
     "errorCodes": [
      "400 - Missing transaction (base64): { \"error\": \"Missing transaction (base64)\" } when req.body.transaction is falsy.",
      "400 - Failed to parse transaction: { \"error\": \"Failed to parse transaction\", \"details\": <parse error message> } when both VersionedTransaction.deserialize and Transaction.from throw.",
      "500 - Simulation failed: { \"error\": \"Simulation failed\", \"details\": <message>, \"stack\": <only when NODE_ENV==='development'> } when connection.simulateTransaction throws (RPC-level failure, distinct from a tx-level err).",
      "500 - Simulation error: { \"error\": \"Simulation error\", \"details\": <message> } for any other failure in the outer try (e.g. getConnection() throws)."
     ],
     "notes": "PUBLIC endpoint — no X-API-Key, no rate limiter (app.post signature is (req, res) only). Labeled a debug endpoint in code (comment at the API layer:2290). Uses getConnection() (the API layer:471) for a live Solana RPC connection rather than the Stryke's node layerRpcRequest passthrough helper, then connection.simulateTransaction(tx, { sigVerify:false, replaceRecentBlockhash:true,...options }). NODE_ENV-gated `stack` is exposed only in the 'Simulation failed' (inner 500) branch, not the outer 500. Logs first 10 simulation logs server-side. Part of the RPC-proxy block (the API layer:2244). success:true means the simulation COMPLETED, not that the tx would land — a reverting tx still returns 200 with a populated `err`. No auth / no rate limit; the 500 paths surface a raw error string.",
     "id": "post-api-rpc-simulate"
    }
   ]
  },
  {
   "group": "system",
   "label": "System",
   "icon": "pulse",
   "groupSummary": "Service health and status. Unauthenticated — use it for uptime monitoring.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/health",
     "name": "Health check",
     "summary": "Liveness probe. Returns ok + the API version + a server timestamp. No API key required. (The root /health alias returns the same.)",
     "authRequired": false,
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"ok\":true,\"version\":\"v2\",\"ts\":1781450000000}",
     "responseNotes": "ok is always true when the service is up. ts is server epoch-ms. Use for monitors/load-balancer health.",
     "errorCodes": [
      "503 service unavailable"
     ],
     "notes": "Unauthenticated. Cheap — safe to poll frequently.",
     "id": "get-api-v2-health"
    },
    {
     "method": "GET",
     "path": "/api/v2/stats",
     "name": "Per-key usage stats",
     "summary": "Usage dashboard for the calling API key over a time range: total calls, error/failure rates, latency, provider spend, a top-routes breakdown, and a daily series for charts. Scoped to the authenticated key only (reads the caller's own call log). Defaults to 7d.",
     "authRequired": true,
     "pathParams": [],
     "queryParams": [
      {
       "name": "range",
       "type": "string",
       "required": false,
       "default": "7d",
       "description": "Window: 24h, 7d, 30d, or 90d. Any other value falls back to 7d."
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"range_ms\":604800000,\"since\":1782999900000,\"summary\":{\"calls\":18432,\"errors\":211,\"failures\":37,\"error_rate\":0.01145,\"failure_rate\":0.00201,\"avg_ms\":142,\"max_ms\":5210,\"provider_calls\":9051,\"provider_cost_usd\":4.821},\"byRoute\":[{\"route\":\"GET /api/v2/tokens/:mint/intel\",\"calls\":6120,\"errors\":44,\"avg_ms\":210,\"cost_usd\":2.11},{\"route\":\"GET /api/v2/wallets/:wallet/portfolio\",\"calls\":3902,\"errors\":9,\"avg_ms\":168,\"cost_usd\":1.34}],\"series\":[{\"ts\":1782950400000,\"calls\":2610,\"failures\":4,\"cost_usd\":0.71}]}",
     "responseNotes": "range_ms = the resolved window in ms; since = window start (epoch-ms). summary: calls, errors (status>=400), failures (status>=500), error_rate/failure_rate (fractions of calls), avg_ms/max_ms (latency), provider_calls (upstream calls made on your behalf), provider_cost_usd (your attributed upstream spend). byRoute[] (top 30 by calls): {route, calls, errors, avg_ms, cost_usd}. series[] (one row per UTC day, ascending): {ts (day bucket, epoch-ms), calls, failures, cost_usd}. All figures are scoped to the calling key only.",
     "errorCodes": [
      "401 no key context (the key carries no server-side id — e.g. the public preview key cannot read usage) / missing/invalid X-API-Key",
      "429 rate limit exceeded",
      "500 { success:false, error } — stats store unavailable"
     ],
     "notes": "Reads the per-key API call log; requires a real registered key (req.apiKeyData.id). VERIFY NOTE: could not exercise live with the public builder preview key — it has no server-side key id, so the endpoint correctly returns 401 {success:false,error:\"no key context\"}; the responseExample is reconstructed from the API layer field-for-field. Mounted at /api/v2/stats (router GET /).",
     "id": "get-api-v2-stats"
    }
   ]
  },
  {
   "label": "Trading API: Trade & Execution",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Build, sign, and broadcast swaps across all supported DEXes plus the on-chain refund/recovery flow. trade scope; per-tenant; supports an idempotency key.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/trade/buy",
     "name": "Buy token",
     "summary": "Buy a token with the caller's per-user wallet using the same TradeExecutor as the Telegram bot. Auto-detects DEX (or honors dex/pool_address hints), injects the app's atomic platform fee, and supports SOL or USDC/USDT settlement (pay_with_mint). Two modes: custodial (server signs+sends, returns signature) or client_signs:true (returns an unsigned base64 tx for the caller to sign+broadcast). Idempotent via the Idempotency-Key header.",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint to buy (base58).",
       "required": true
      },
      {
       "name": "amount_in",
       "type": "number",
       "description": "Amount to SPEND — SOL by default, or whole pay_with_mint units (e.g. USDC). Must be 0.00001–100. Aliases: amount_sol, amount.",
       "required": true
      },
      {
       "name": "dex",
       "type": "string",
       "description": "DEX to trade on (auto-detected if omitted): pumpfun, pumpswap, raydium_cpmm, etc. Alias: dex_type.",
       "required": false
      },
      {
       "name": "pool_address",
       "type": "string",
       "description": "Specific pool/pair address, skips detection. Alias: pair_address.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps (default 1000 = 10%). Max 100000.",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via Jito bundle for MEV protection (default false).",
       "required": false
      },
      {
       "name": "tip_lamports",
       "type": "int",
       "description": "MEV/priority tip in lamports (default 1500000 = 0.0015 SOL).",
       "required": false
      },
      {
       "name": "compute_unit_price",
       "type": "int",
       "description": "Priority fee, micro-lamports per CU (default V1_DEFAULT_CU_PRICE, 500000). Alias: cu_price.",
       "required": false
      },
      {
       "name": "priority_micro_lamports",
       "type": "int",
       "description": "Priority fee µL/CU; takes precedence over compute_unit_price. Clamped to V1_MAX_PRIORITY_FEE_LAMPORTS. Aliases: priorityFee, priority_fee.",
       "required": false
      },
      {
       "name": "pay_with_mint",
       "type": "string",
       "description": "Settlement/input currency mint to pay with (default SOL). Accepts SOL/WSOL/USDC/USDT only; token must be in a pool quoted in that currency. Alias: input_mint.",
       "required": false
      },
      {
       "name": "client_signs",
       "type": "bool",
       "description": "If true, return an unsigned base64 tx instead of executing (caller signs + broadcasts). Requires wallet pubkey.",
       "required": false
      },
      {
       "name": "wallet",
       "type": "string",
       "description": "Trading wallet pubkey (required for client_signs payer). Alias: trading_wallet.",
       "required": false
      },
      {
       "name": "use_router",
       "type": "bool",
       "description": "Route swap through the on-chain Stryke Router (default false = direct DEX). Only honored on client_signs builds. Aliases: useRouter, route_via_program.",
       "required": false
      },
      {
       "name": "mev_tip_lamports",
       "type": "int",
       "description": "Jito tip (total lamports) baked into a client_signs build; omit → 100000 default, 0 → opt-out. Aliases: mevTipLamports, mevTip, jitoTip.",
       "required": false
      },
      {
       "name": "mev_tip_bps",
       "type": "int",
       "description": "Jito tip as bps of quoted trade value (resolved server-side). Overridden by mev_tip_lamports. Alias: mevTipBps.",
       "required": false
      },
      {
       "name": "fee_bps",
       "type": "int",
       "description": "Per-trade platform fee bps override — only honored with the fee_routing scope (else 403). Alias: fee_bps_override.",
       "required": false
      },
      {
       "name": "fee_wallet",
       "type": "string",
       "description": "Override destination wallet for this trade's platform fee — only with fee_routing scope (else 403). Alias: feeWallet.",
       "required": false
      },
      {
       "name": "nonce_account",
       "type": "string",
       "description": "Durable nonce account pubkey (Turbo Mode); pass with nonce_value.",
       "required": false
      },
      {
       "name": "nonce_value",
       "type": "string",
       "description": "Current nonce hash (base58); required with nonce_account.",
       "required": false
      },
      {
       "name": "simulate",
       "type": "bool",
       "description": "Simulate only — runs full pipeline via simulateTransaction, no on-chain submit (default false).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"signature\":\"5xQ...\",\"amount_received\":123456789,\"amount_spent\":1000000,\"timing\":{\"total_ms\":820,\"pool_fetch_ms\":40,\"detect_ms\":120,\"confirm_method\":\"grpc\",\"mev_service\":\"jito\",\"dex_name\":\"PumpFun\",\"landed_slot\":255123456}}",
     "responseNotes": "Custodial mode returns the TradeResponse struct: success, signature, amount_received (BUY = tokens received, raw units), amount_spent (BUY = SOL lamports spent), timing (per-stage ms + confirm_method/mev_service/dex_name/landed_slot). simulate adds simulated:true, compute_units, logs[]. client_signs:true instead returns {\"success\":true,\"data\":{\"unsigned_tx_base64\":\"...\",\"recent_blockhash\":\"...\",\"last_valid_block_height\":123}}.",
     "errorCodes": [
      {
       "code": 400,
       "when": "bad_amount (amount_in out of 0.00001–100), bad_wallet (client_signs without wallet), no_active_wallet, bad_nonce, or token_not_found"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope, or fee_routing_forbidden when setting fee_bps/fee_wallet without the fee_routing scope"
      },
      {
       "code": 422,
       "when": "unsupported_settlement_mint, fee_out_of_range, or no_settlement_route (client_signs, no pool in the requested currency)"
      },
      {
       "code": 500,
       "when": "trade_error or no_signer (custodial with no resolved signer)"
      },
      {
       "code": 503,
       "when": "no_trading_resources (executor not wired) or build_tx_failed (client_signs build)"
      }
     ],
     "notes": "Idempotency-Key header supported (tenancy idempotency_layer). X-User-Ref header identifies the per-user wallet.",
     "id": "trade-buy"
    },
    {
     "method": "POST",
     "path": "/v1/trade/sell",
     "name": "Sell token",
     "summary": "Sell a token from the caller's per-user wallet via the same TradeExecutor as the bot. Sell by percent (default 100) or exact amount_in (base units, converted to a percent of the ATA balance). Injects the app's platform fee (deducted from output), supports SOL or USDC/USDT settlement (receive_mint), and offers custodial or client_signs unsigned-tx modes. Closes/records the position on a 100% sell.",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint to sell (base58).",
       "required": true
      },
      {
       "name": "percent",
       "type": "int",
       "description": "Percent of balance to sell, 1–100 (default 100 when amount_in omitted).",
       "required": false
      },
      {
       "name": "amount_in",
       "type": "int",
       "description": "Exact token amount (base units) to sell; converted to a percent of the ATA balance. Alias: amount_tokens.",
       "required": false
      },
      {
       "name": "dex",
       "type": "string",
       "description": "DEX to trade on (auto-detected if omitted). Alias: dex_type.",
       "required": false
      },
      {
       "name": "pool_address",
       "type": "string",
       "description": "Specific pool/pair address, skips detection. Alias: pair_address.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps (default 1000). Max 100000.",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via Jito bundle (default false).",
       "required": false
      },
      {
       "name": "close_token_account",
       "type": "bool",
       "description": "Close the token ATA after a full sell to reclaim rent. Alias: close_ata.",
       "required": false
      },
      {
       "name": "tip_lamports",
       "type": "int",
       "description": "MEV/priority tip in lamports (default 1500000).",
       "required": false
      },
      {
       "name": "compute_unit_price",
       "type": "int",
       "description": "Priority fee µL/CU (default 500000). Alias: cu_price.",
       "required": false
      },
      {
       "name": "priority_micro_lamports",
       "type": "int",
       "description": "Priority fee µL/CU; precedence over compute_unit_price; clamped. Aliases: priorityFee, priority_fee.",
       "required": false
      },
      {
       "name": "receive_mint",
       "type": "string",
       "description": "Currency to RECEIVE (default SOL); accepts SOL/WSOL/USDC/USDT; token must be in a pool quoted in it. Alias: output_mint.",
       "required": false
      },
      {
       "name": "client_signs",
       "type": "bool",
       "description": "If true, return an unsigned base64 tx instead of executing.",
       "required": false
      },
      {
       "name": "wallet",
       "type": "string",
       "description": "Trading wallet pubkey (client_signs payer). Alias: trading_wallet.",
       "required": false
      },
      {
       "name": "use_router",
       "type": "bool",
       "description": "Route through the on-chain Stryke Router (default false = direct DEX). client_signs only. Aliases: useRouter, route_via_program.",
       "required": false
      },
      {
       "name": "mev_tip_lamports",
       "type": "int",
       "description": "Jito tip (total lamports) for a client_signs build; omit → 100000, 0 → opt-out. Aliases: mevTipLamports, mevTip, jitoTip.",
       "required": false
      },
      {
       "name": "mev_tip_bps",
       "type": "int",
       "description": "Jito tip as bps of estimated SOL proceeds (resolved server-side). Overridden by mev_tip_lamports. Alias: mevTipBps.",
       "required": false
      },
      {
       "name": "fee_bps",
       "type": "int",
       "description": "Per-trade platform fee bps override — fee_routing scope only (else 403). Alias: fee_bps_override.",
       "required": false
      },
      {
       "name": "fee_wallet",
       "type": "string",
       "description": "Override platform-fee destination — fee_routing scope only (else 403). Alias: feeWallet.",
       "required": false
      },
      {
       "name": "nonce_account",
       "type": "string",
       "description": "Durable nonce account pubkey (Turbo Mode).",
       "required": false
      },
      {
       "name": "nonce_value",
       "type": "string",
       "description": "Current nonce hash; required with nonce_account.",
       "required": false
      },
      {
       "name": "simulate",
       "type": "bool",
       "description": "Simulate only, no on-chain submit (default false).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"signature\":\"3aB...\",\"amount_received\":980000000,\"amount_spent\":123456789,\"timing\":{\"total_ms\":760,\"pool_fetch_ms\":35,\"detect_ms\":90,\"confirm_method\":\"grpc\",\"mev_service\":\"jito\",\"dex_name\":\"PumpSwap\"}}",
     "responseNotes": "Custodial mode returns TradeResponse: amount_received (SELL = SOL lamports received), amount_spent (SELL = token amount sold), timing. client_signs:true returns {\"success\":true,\"data\":{\"unsigned_tx_base64\":\"...\",\"recent_blockhash\":\"...\",\"last_valid_block_height\":123}}. simulate adds simulated/compute_units/logs.",
     "errorCodes": [
      {
       "code": 400,
       "when": "bad_amount (amount_in=0), bad_mint, no_tokens (nothing to sell), bad_percent (not 1–100), zero_amount (resolved sell amount 0), bad_wallet, no_active_wallet, bad_nonce, or token_not_found"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope, or fee_routing_forbidden when overriding fee_bps/fee_wallet"
      },
      {
       "code": 422,
       "when": "unsupported_settlement_mint, fee_out_of_range, or no_settlement_route (client_signs)"
      },
      {
       "code": 500,
       "when": "trade_error, no_signer, or balance_error"
      },
      {
       "code": 503,
       "when": "no_trading_resources or build_tx_failed (client_signs build)"
      }
     ],
     "notes": "Idempotency-Key header supported. On a 100% custodial sell the position is closed with PnL in the background.",
     "id": "trade-sell"
    },
    {
     "method": "POST",
     "path": "/v1/trade/buy-multi-hop",
     "name": "Buy token (multi-hop alias)",
     "summary": "Convenience alias that delegates to /v1/trade/buy with SOL settlement (the executor handles SOL↔USDC bridging automatically). Accepts a slim multi-hop body; bridge_slippage_bps and intermediate_mint are accepted but ignored (routing is automatic).",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint to buy.",
       "required": true
      },
      {
       "name": "amount_sol",
       "type": "number",
       "description": "SOL to spend.",
       "required": true
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX hint.",
       "required": false
      },
      {
       "name": "pair_address",
       "type": "string",
       "description": "Optional pool/pair address hint.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps (default 1000).",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via Jito bundle (default false).",
       "required": false
      },
      {
       "name": "bridge_slippage_bps",
       "type": "int",
       "description": "Accepted but ignored — multi-hop routing/bridging is automatic.",
       "required": false
      },
      {
       "name": "intermediate_mint",
       "type": "string",
       "description": "Accepted but ignored — bridge currency is chosen automatically.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"signature\":\"5xQ...\",\"amount_received\":123456789,\"amount_spent\":1000000,\"timing\":{\"total_ms\":900,\"pool_fetch_ms\":50}}",
     "responseNotes": "Identical response shape to /v1/trade/buy (custodial TradeResponse). Tip/cu_price default; nonce and simulate are forced off by the alias.",
     "errorCodes": [
      {
       "code": 400,
       "when": "bad_amount, no_active_wallet, or token_not_found"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 500,
       "when": "trade_error"
      },
      {
       "code": 503,
       "when": "no_trading_resources"
      }
     ],
     "notes": "Thin wrapper over v1_buy — multi-hop bridging is automatic in the executor. Idempotency-Key supported.",
     "id": "trade-buy-multi-hop"
    },
    {
     "method": "POST",
     "path": "/v1/trade/sell-multi-hop",
     "name": "Sell token (multi-hop alias)",
     "summary": "Convenience alias that delegates to /v1/trade/sell with SOL settlement (the executor handles SOL↔USDC bridging automatically). bridge_slippage_bps and intermediate_mint are accepted but ignored.",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint to sell.",
       "required": true
      },
      {
       "name": "percent",
       "type": "int",
       "description": "Percent of balance to sell, 1–100 (default 100).",
       "required": false
      },
      {
       "name": "amount_tokens",
       "type": "int",
       "description": "Exact token amount (base units) to sell.",
       "required": false
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX hint.",
       "required": false
      },
      {
       "name": "pair_address",
       "type": "string",
       "description": "Optional pool/pair address hint.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps (default 1000).",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via Jito bundle (default false).",
       "required": false
      },
      {
       "name": "close_ata",
       "type": "bool",
       "description": "Close the token ATA after a full sell.",
       "required": false
      },
      {
       "name": "bridge_slippage_bps",
       "type": "int",
       "description": "Accepted but ignored — routing/bridging is automatic.",
       "required": false
      },
      {
       "name": "intermediate_mint",
       "type": "string",
       "description": "Accepted but ignored — bridge currency is chosen automatically.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"signature\":\"3aB...\",\"amount_received\":980000000,\"amount_spent\":123456789,\"timing\":{\"total_ms\":850,\"pool_fetch_ms\":45}}",
     "responseNotes": "Identical response shape to /v1/trade/sell (custodial TradeResponse). Tip/cu_price default; nonce and simulate forced off by the alias.",
     "errorCodes": [
      {
       "code": 400,
       "when": "bad_mint, no_tokens, bad_percent, no_active_wallet, or token_not_found"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 500,
       "when": "trade_error or balance_error"
      },
      {
       "code": 503,
       "when": "no_trading_resources"
      }
     ],
     "notes": "Thin wrapper over v1_sell — multi-hop bridging is automatic. Idempotency-Key supported.",
     "id": "trade-sell-multi-hop"
    },
    {
     "method": "GET",
     "path": "/v1/trade/pool-params",
     "name": "Pool params",
     "summary": "Resolve and return the on-chain pool parameters for a mint (DEX type, quote mint, SOL-quoted flag) using the executor's cached pool-param fetch. Useful for verifying routing before a trade. Read-only, no wallet needed.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX hint to skip detection.",
       "required": false
      },
      {
       "name": "pair_address",
       "type": "string",
       "description": "Optional pool/pair address hint.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"dex_type\":\"PumpFun\",\"mint\":\"So1...\",\"is_sol_quoted\":true,\"quote_mint\":\"So11111111111111111111111111111111111111112\",\"fetch_ms\":42,\"detect_ms\":110,\"cache_hit\":false}",
     "responseNotes": "is_sol_quoted: whether the pool is quoted in SOL/WSOL. quote_mint: the pool's quote currency mint (null if none). cache_hit: pool-params cache hit. fetch_ms/detect_ms: timings.",
     "errorCodes": [
      {
       "code": 400,
       "when": "token not found / unresolvable, or invalid mint address"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "no_trading_resources or pool params fetch failed"
      }
     ],
     "id": "trade-pool-params"
    },
    {
     "method": "GET",
     "path": "/v1/trade/estimate",
     "name": "Estimate output",
     "summary": "Estimate the output and price impact for a buy or sell of a given amount, using live (best-effort on-chain refreshed) reserves from the resolved pool. Same path as /v1/trade/quote's underlying estimate but with simple query params and no fee accounting.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      },
      {
       "name": "amount",
       "type": "int",
       "description": "Input amount in raw base units (lamports for a SOL buy, token base units for a sell).",
       "required": false
      },
      {
       "name": "side",
       "type": "string",
       "description": "buy or sell (default buy).",
       "required": false
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX hint.",
       "required": false
      },
      {
       "name": "pair_address",
       "type": "string",
       "description": "Optional pool/pair address hint.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"dex_type\":\"PumpFun\",\"mint\":\"So1...\",\"is_sol_quoted\":true,\"quote_mint\":\"So111...112\",\"side\":\"buy\",\"amount\":1000000,\"estimated_output\":123456789,\"price_impact_pct\":0.42,\"fetch_ms\":48,\"detect_ms\":105}",
     "responseNotes": "estimated_output: estimated tokens (buy) or SOL/quote lamports (sell) for the given amount. price_impact_pct: estimated price impact percent. Reflects fresh reserves written back to cache so a subsequent build matches.",
     "errorCodes": [
      {
       "code": 400,
       "when": "token not found / unresolvable, or invalid mint address"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "no_trading_resources or pool params fetch failed"
      }
     ],
     "id": "trade-estimate"
    },
    {
     "method": "POST",
     "path": "/v1/trade/quote",
     "name": "Trade quote",
     "summary": "Pre-trade preview WITHOUT building a transaction. One side of input_mint/output_mint must be a quote currency (SOL/WSOL/USDC/USDT), the other the token; buy = spend quote→receive token, sell = sell token→receive quote. Returns gross + net (after platform fee) outputs, the slippage floor the built tx will enforce, price impact, and the route/DEX. Rejects token↔token and cross-currency mismatches.",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "input_mint",
       "type": "string",
       "description": "Mint being spent (base58). For a buy this is the quote currency; for a sell it is the token.",
       "required": true
      },
      {
       "name": "output_mint",
       "type": "string",
       "description": "Mint being received (base58). For a buy this is the token; for a sell it is the quote currency.",
       "required": true
      },
      {
       "name": "amount",
       "type": "int",
       "description": "Amount of input_mint in raw base units.",
       "required": true
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps (default 1000), used to compute min_out_amount.",
       "required": false
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX hint to skip detection.",
       "required": false
      },
      {
       "name": "pair_address",
       "type": "string",
       "description": "Optional pool/pair address hint.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"side\":\"buy\",\"input_mint\":\"So111...112\",\"in_amount\":1000000,\"in_decimals\":9,\"out_mint\":\"Tok...\",\"out_amount\":123456789,\"out_decimals\":6,\"min_out_amount\":111111110,\"net_out_amount\":123456789,\"min_net_out_amount\":111111110,\"platform_fee\":10000,\"fee_bps\":100,\"price_impact_bps\":42,\"slippage_bps\":1000,\"token\":{\"symbol\":\"TOK\",\"name\":\"Token\",\"decimals\":6,\"price_sol\":0.0000012,\"price_usd\":0.00021,\"liquidity\":54000.0},\"route\":{\"dex\":\"PumpFun\",\"pair_address\":\"Pa1r...\",\"quote_mint\":\"So111...112\",\"is_sol_quoted\":true},\"fetch_ms\":50}",
     "responseNotes": "out_amount = gross swap output; min_out_amount = slippage floor the built tx encodes. net_out_amount/min_net_out_amount account for the platform fee (BUY net==gross tokens, fee is extra cost; SELL net = gross − fee). platform_fee is in the quote currency. price_impact_bps, route, and token block included.",
     "errorCodes": [
      {
       "code": 400,
       "when": "amount=0, both sides quote currencies, token↔token quote unsupported, or invalid token mint"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 422,
       "when": "could not estimate output (illiquid pool / amount too small), or cross-currency quote (pool quoted in a different currency than specified)"
      },
      {
       "code": 503,
       "when": "no_trading_resources or pool params fetch failed"
      }
     ],
     "id": "trade-quote"
    },
    {
     "method": "GET",
     "path": "/v1/trade/bundle-status",
     "name": "Bundle / landing status",
     "summary": "Report the ACTUAL landing of an MEV broadcast so a client can honestly badge 'MEV protected' only when the Jito bundle itself landed. Pass bundle_id (from the broadcast response) for Jito getBundleStatuses attribution and/or signature for definitive on-chain landing (incl. the RPC-fallback leg). At least one is required.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "bundle_id",
       "type": "string",
       "description": "Bundle id from the broadcast response (Jito attribution). Preferred for MEV badging.",
       "required": false
      },
      {
       "name": "signature",
       "type": "string",
       "description": "Tx signature for definitive on-chain landing (catches the RPC-fallback leg).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"bundle_id\":\"abc\",\"bundle_status\":\"landed\",\"bundle_landed_slot\":255123456,\"bundle_confirmation\":\"confirmed\",\"signature\":\"3aB...\",\"onchain_status\":\"landed\",\"onchain_slot\":255123456,\"onchain_confirmation\":\"confirmed\",\"landed\":true,\"mev_protected\":true}",
     "responseNotes": "landed: tx on-chain via either leg. mev_protected: true ONLY when the Jito bundle itself landed (not the public-RPC fallback). bundle_status ∈ landed/failed/pending/unknown; onchain_status ∈ landed/failed/pending/unavailable. Keys appear only for the inputs provided.",
     "errorCodes": [
      {
       "code": 400,
       "when": "neither bundle_id nor signature provided"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      }
     ],
     "id": "trade-bundle-status"
    },
    {
     "method": "GET",
     "path": "/v1/trade/balance",
     "name": "Token balance",
     "summary": "Return the token balance for the requesting user's wallet, split across the seed-derived and standard ATAs. Resolves the per-user wallet from the authenticated session/X-User-Ref.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"wallet\":\"Wa11et...\",\"mint\":\"Tok...\",\"total_balance\":123456789,\"seed_ata_balance\":123456789,\"standard_ata_balance\":0}",
     "responseNotes": "total_balance = seed + standard; the two ATA balances are broken out (raw base units).",
     "errorCodes": [
      {
       "code": 400,
       "when": "no_active_wallet or invalid mint address"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "no_trading_resources or balance fetch failed"
      }
     ],
     "id": "trade-balance"
    },
    {
     "method": "GET",
     "path": "/v1/trade/sol-balance",
     "name": "SOL balance",
     "summary": "Return the SOL balance (lamports + SOL) for the requesting user's wallet, resolved from the authenticated session/X-User-Ref. No params.",
     "authRequired": true,
     "scope": "trade",
     "responseExample": "{\"success\":true,\"wallet\":\"Wa11et...\",\"balance_lamports\":2500000000,\"balance_sol\":2.5}",
     "responseNotes": "balance_lamports raw; balance_sol = lamports / 1e9.",
     "errorCodes": [
      {
       "code": 400,
       "when": "no_active_wallet"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "no_trading_resources or failed to get SOL balance"
      }
     ],
     "id": "trade-sol-balance"
    },
    {
     "method": "GET",
     "path": "/v1/trade/detect",
     "name": "Detect DEX",
     "summary": "Auto-detect the DEX/pool for a mint without executing a trade, using the same token-info path the bot uses when a user pastes an address (the DEX index + indexer). Returns DEX type, pair address, and basic name/symbol.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"mint\":\"Tok...\",\"dex_type\":\"PumpFun\",\"dex_name\":\"PumpFun\",\"pair_address\":\"Pa1r...\",\"data_source\":\"indexer\",\"name\":\"Token\",\"symbol\":\"TOK\",\"detect_ms\":118}",
     "responseNotes": "data_source = where the info came from (indexer/the DEX index/etc.). detect_ms = detection latency.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 404,
       "when": "token not found on any supported DEX, or pool drained/$0 liquidity"
      },
      {
       "code": 503,
       "when": "no_trading_resources"
      }
     ],
     "id": "trade-detect"
    },
    {
     "method": "POST",
     "path": "/v1/trade/broadcast",
     "name": "Broadcast signed tx",
     "summary": "Relay a client-signed base64 VersionedTransaction to the network through the executor's SWQOS-aware submit pipeline (same endpoints/pool as custodial trades). Auto-detects an embedded Jito tip and offers the tx via sendBundle first (with public-RPC fallback). submitted_via reflects the attempted strategy, NOT a landing guarantee — poll /v1/trade/bundle-status.",
     "authRequired": true,
     "scope": "trade",
     "bodyParams": [
      {
       "name": "signed_tx_base64",
       "type": "string",
       "description": "Base64-encoded signed VersionedTransaction (max 2 KiB on the wire).",
       "required": true
      },
      {
       "name": "tip_lamports",
       "type": "int",
       "description": "Informational only — tip already embedded in the signed tx; the submit path adds no tip instructions.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"signature\":\"3aB...\",\"submitted_via\":\"jito_bundle\",\"bundle_id\":\"abc\",\"rpc_fallback\":true,\"landing\":\"pending\"}}",
     "responseNotes": "submitted_via: jito_bundle (tip detected) | swqos (no tip) | fallback_rpc (no executor wired). bundle_id returned for bundle attribution; rpc_fallback true when a public fallback leg exists; landing is always 'pending' at broadcast time.",
     "errorCodes": [
      {
       "code": 400,
       "when": "bad_base64, or bad_tx (exceeds 2 KiB / undecodable VersionedTransaction)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "submit_failed (executor) or rpc (fallback send)"
      }
     ],
     "notes": "Idempotency-Key supported. user_id (tenancy) validated but not otherwise used.",
     "id": "trade-broadcast"
    },
    {
     "method": "GET",
     "path": "/v1/token/info",
     "name": "Token info",
     "summary": "Full token info (price, market cap, liquidity, 24h volume, DEX/pair, bonding-curve and authority flags) using the same fetch path the bot uses when a user pastes an address. No wallet needed.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"mint\":\"Tok...\",\"name\":\"Token\",\"symbol\":\"TOK\",\"price_usd\":0.00021,\"price_sol\":0.0000012,\"market_cap\":210000.0,\"liquidity\":54000.0,\"volume_24h\":120000.0,\"dex_type\":\"PumpFun\",\"dex_name\":\"PumpFun\",\"pair_address\":\"Pa1r...\",\"data_source\":\"indexer\",\"is_bonding_curve\":true,\"bonding_curve_percent\":42.5,\"is_renounced\":true,\"is_freezable\":false,\"fetch_ms\":95}",
     "responseNotes": "Includes price_usd/price_sol, market_cap, liquidity, volume_24h, DEX info, plus is_bonding_curve/bonding_curve_percent/is_renounced/is_freezable risk flags.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 404,
       "when": "token not found on any supported DEX, or pool drained/$0 liquidity"
      },
      {
       "code": 503,
       "when": "no_trading_resources"
      }
     ],
     "id": "token-info"
    },
    {
     "method": "GET",
     "path": "/v1/test/discover",
     "name": "Discover pools on-chain",
     "summary": "Diagnostic: directly run on-chain pool discovery for a mint (PDA batch + GPA scan), bypassing the indexer DB. Returns every pool found with its DEX, quote mint, and program id, plus discovery latency. Intended for routing/debugging.",
     "authRequired": true,
     "scope": "trade",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint (base58).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"mint\":\"Tok...\",\"discover_ms\":210,\"pools_found\":2,\"pools\":[{\"pool_address\":\"Pa1r...\",\"dex_type\":\"PumpSwap\",\"quote_mint\":\"So111...112\",\"program_id\":\"pAMM...\"}]}",
     "responseNotes": "pools[] each has pool_address, dex_type, quote_mint, program_id. pools_found = count; discover_ms = on-chain discovery latency.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid mint"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "missing trade scope"
      },
      {
       "code": 503,
       "when": "no_trading_resources"
      }
     ],
     "id": "test-discover"
    },
    {
     "method": "GET",
     "path": "/v1/refund/scan",
     "name": "Scan wallet for reclaimable rent",
     "summary": "Scans the caller's per-user active wallet for empty token accounts (ATAs) and dust-balance accounts, reporting the SOL rent reclaimable from each. Read-only (no on-chain tx); resolves the wallet from the tenant's X-User-Ref context.",
     "authRequired": true,
     "scope": "trade",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"wallet\":\"7xKf...\",\"empty_accounts\":[{\"pubkey\":\"...\",\"mint\":\"...\",\"rent_lamports\":2039280,\"rent_sol\":\"0.00203928\",\"token_program\":\"...\"}],\"empty_count\":1,\"dust_accounts\":[{\"pubkey\":\"...\",\"mint\":\"...\",\"balance\":1234,\"decimals\":6,\"rent_lamports\":2039280,\"rent_sol\":\"0.00203928\",\"token_program\":\"...\"}],\"dust_count\":1,\"total_accounts\":2,\"empty_rent_sol\":\"0.00203928\",\"dust_rent_sol\":\"0.00203928\",\"total_reclaimable_sol\":\"0.00407856\",\"total_reclaimable_lamports\":4078560}",
     "responseNotes": "empty_accounts = zero-balance ATAs closeable immediately; dust_accounts = non-zero token balances that need burn-then-close. *_rent_sol are human-formatted strings; total_reclaimable_lamports is the raw u64. token_program distinguishes SPL Token vs Token-2022.",
     "errorCodes": [
      {
       "code": 400,
       "when": "no active wallet for the tenant (error.code = no_active_wallet)"
      },
      {
       "code": 503,
       "when": "trading executor not wired in this deployment mode (error.code = no_trading_resources), or RPC scan failed"
      }
     ],
     "id": "refund-scan"
    },
    {
     "method": "POST",
     "path": "/v1/refund/close-empty",
     "name": "Close empty token accounts",
     "summary": "Scans then closes all empty (zero-balance) token accounts for the caller's wallet in a single batch on-chain transaction, reclaiming their SOL rent. Idempotency-key supported (trade-scope idempotency layer). Frozen (honeypot) accounts are skipped and reported.",
     "authRequired": true,
     "scope": "trade",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"closed\":3,\"failed\":0,\"frozen\":0,\"reclaimed_lamports\":6117840,\"reclaimed_sol\":\"0.0061178\",\"signature\":\"5xQ...\",\"total_empty_found\":3,\"error\":null}",
     "responseNotes": "success = true when closed>0 OR (no failures and no frozen). closed/failed/frozen are counts; reclaimed_lamports/_sol is rent recovered; signature is the close tx (null if none sent). If frozen>0, error explains the account is FROZEN (honeypot). When nothing to close, returns {success:true,message:\"No empty token accounts found\",closed:0,reclaimed_sol:\"0\"}.",
     "errorCodes": [
      {
       "code": 400,
       "when": "no active wallet for the tenant (error.code = no_active_wallet)"
      },
      {
       "code": 503,
       "when": "trading resources not available (no_trading_resources), scan failed, or close tx failed"
      }
     ],
     "notes": "Supports Idempotency-Key header (trade-scope idempotency layer).",
     "id": "refund-close-empty"
    },
    {
     "method": "POST",
     "path": "/v1/refund/burn-and-close",
     "name": "Burn dust and close accounts",
     "summary": "Scans for token accounts holding dust balances, burns the remaining tokens, and closes the accounts to reclaim SOL rent — in one on-chain batch. Optional max_accounts caps how many are processed this call. Frozen accounts are skipped and reported.",
     "authRequired": true,
     "scope": "trade",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "max_accounts",
       "type": "int",
       "description": "Maximum number of dust accounts to burn+close in this call. Defaults to all if omitted.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"closed\":2,\"failed\":0,\"frozen\":0,\"reclaimed_lamports\":4078560,\"reclaimed_sol\":\"0.00407856\",\"signature\":\"3aB...\",\"total_dust_found\":2,\"error\":null}",
     "responseNotes": "success = true when closed>0 OR (no failures and no frozen). total_dust_found = number of dust accounts attempted (after max_accounts cap). signature is the burn+close tx (null if none). If frozen>0, error notes the account is FROZEN (honeypot). When no dust found, returns {success:true,message:\"No dust token accounts found\",closed:0,reclaimed_sol:\"0\"}.",
     "errorCodes": [
      {
       "code": 400,
       "when": "no active wallet for the tenant (no_active_wallet)"
      },
      {
       "code": 422,
       "when": "malformed JSON body (Apijson error envelope)"
      },
      {
       "code": 503,
       "when": "trading resources not available (no_trading_resources), scan failed, or burn+close tx failed"
      }
     ],
     "notes": "Supports Idempotency-Key header (trade-scope idempotency layer).",
     "id": "refund-burn-and-close"
    },
    {
     "method": "POST",
     "path": "/v1/refund/close-nonce",
     "name": "Close nonce accounts",
     "summary": "Closes durable-nonce accounts for the caller's wallet and reclaims their rent. Pass specific nonce_pubkeys to close those, or omit/empty to close ALL nonce (pro) accounts looked up from the DB for this wallet. Closes in one on-chain batch.",
     "authRequired": true,
     "scope": "trade",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "nonce_pubkeys",
       "type": "string[]",
       "description": "Specific nonce account pubkeys to close. If omitted or empty, closes ALL nonce accounts for this wallet (resolved from DB pro_accounts).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"closed\":4,\"failed\":0,\"reclaimed_lamports\":5760000,\"reclaimed_sol\":\"0.00576\",\"signature\":\"9zC...\",\"total_nonce_accounts\":4}",
     "responseNotes": "closed/failed are counts; reclaimed_lamports/_sol is rent recovered; signature is the batch close tx (null if none); total_nonce_accounts is how many were targeted. When there are no nonce accounts to close, returns {success:true,message:\"No nonce accounts to close\",closed:0,reclaimed_sol:\"0\"}.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid pubkey in nonce_pubkeys, or no active wallet (no_active_wallet)"
      },
      {
       "code": 404,
       "when": "wallet not found in DB (when resolving all nonce accounts)"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson error envelope)"
      },
      {
       "code": 500,
       "when": "DB error listing nonce accounts, or invalid nonce pubkey stored in DB"
      },
      {
       "code": 503,
       "when": "trading resources not available (no_trading_resources), or close-nonce tx failed"
      }
     ],
     "notes": "Supports Idempotency-Key header (trade-scope idempotency layer).",
     "id": "refund-close-nonce"
    }
   ]
  },
  {
   "label": "Trading API: Wallets",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Custodial trading wallets — create, import, list, rename, switch, withdraw, and migrate. wallet scope; per-tenant.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/wallet/list",
     "name": "List wallets",
     "summary": "Lists all wallets owned by the requesting tenant user (resolved from X-User-Ref). Returns each wallet's id, name, public key, active flag, generated flag, and migration timestamp.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":[{\"id\":12,\"name\":\"Wallet\",\"public_key\":\"7xK… \",\"is_active\":true,\"is_generated\":true,\"migrated_at\":null}]}",
     "responseNotes": "data is an array of wallet objects. is_generated=true means the keypair was created by the API (not imported). migrated_at is an RFC3339 string or null. Wallets are scoped to the caller's tenant user.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "id": "wallet-list"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/create",
     "name": "Create wallet",
     "summary": "Generates a brand-new Solana keypair server-side, stores it encrypted, and associates it with the tenant user. Optionally makes it the active wallet.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "name",
       "type": "string",
       "description": "Display name for the wallet. Defaults to \"Wallet\" if omitted.",
       "required": false
      },
      {
       "name": "set_active",
       "type": "bool",
       "description": "If true, marks the new wallet as the user's active wallet. Defaults to false.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"id\":13,\"name\":\"Wallet\",\"public_key\":\"7xKXtg2CW…\"}}",
     "responseNotes": "data.id is the new wallet row id; public_key is the generated address. The private key is never returned. Supports Idempotency-Key header to dedupe retries.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 409,
       "when": "duplicate wallet — a wallet with this public key already exists (error.code=duplicate_wallet)"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported (wallet routes pass through the idempotency layer).",
     "id": "wallet-create"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/import",
     "name": "Import wallet",
     "summary": "Imports an existing wallet from a base58-encoded 64-byte secret key, stores it encrypted, and associates it with the tenant user. Key is validated without panicking; malformed keys are rejected.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "private_key",
       "type": "string",
       "description": "Base58-encoded full 64-byte secret key.",
       "required": true
      },
      {
       "name": "name",
       "type": "string",
       "description": "Display name for the wallet. Defaults to \"Imported Wallet\" if omitted.",
       "required": false
      },
      {
       "name": "set_active",
       "type": "bool",
       "description": "If true, marks the imported wallet as active. Defaults to false.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"id\":14,\"name\":\"Imported Wallet\",\"public_key\":\"9aBc…\"}}",
     "responseNotes": "data.id is the new wallet row id; public_key is derived from the imported key. The secret is never echoed back.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid base58 private key / not a valid 64-byte secret key (error.code=bad_private_key)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 409,
       "when": "duplicate wallet — public key already exists (error.code=duplicate_wallet)"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported.",
     "id": "wallet-import"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/rename",
     "name": "Rename wallet",
     "summary": "Renames an existing wallet owned by the tenant user. The rename is ownership-scoped — only wallets belonging to the caller can be renamed.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallet_id",
       "type": "int",
       "description": "ID of the wallet to rename (must belong to the caller).",
       "required": true
      },
      {
       "name": "name",
       "type": "string",
       "description": "New display name. Cannot be empty/whitespace.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"wallet_id\":12,\"name\":\"Main Wallet\"}}",
     "responseNotes": "Echoes the wallet_id and new name on success.",
     "errorCodes": [
      {
       "code": 400,
       "when": "name is empty/whitespace (error.code=bad_request)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported.",
     "id": "wallet-rename"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/switch",
     "name": "Switch active wallet",
     "summary": "Sets the given wallet as the tenant user's active wallet. Scoped to the caller — only the user's own wallets can be activated.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallet_id",
       "type": "int",
       "description": "ID of the wallet to make active (must belong to the caller).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"active_wallet_id\":12}}",
     "responseNotes": "data.active_wallet_id confirms the newly active wallet.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported.",
     "id": "wallet-switch"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/withdraw",
     "name": "Withdraw SOL",
     "summary": "Sends SOL from the user's active wallet to an external address on-chain. Validates the destination, amount bounds, active-wallet presence, and self-send. Requires an executor/RPC deployment.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "to_address",
       "type": "string",
       "description": "Destination Solana address (base58 pubkey).",
       "required": true
      },
      {
       "name": "amount_sol",
       "type": "number",
       "description": "Amount of SOL to send. Must be > 0 and ≤ 1,000,000.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"signature\":\"5Hx…\",\"to_address\":\"9aBc…\",\"amount_sol\":0.5,\"from\":\"7xK…\"}}",
     "responseNotes": "On success, data.signature is the on-chain transfer signature; from is the active wallet pubkey. The send is executed from the caller's active wallet keypair, loaded server-side.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid destination (bad_address), bad amount out of range (bad_amount), no active wallet (no_active_wallet), or destination equals own wallet (same_wallet); also insufficient balance (insufficient_balance)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 500,
       "when": "wallet keypair load failure (error.code=wallet_load)"
      },
      {
       "code": 502,
       "when": "chain/RPC send failure (error.code=withdraw_failed)"
      },
      {
       "code": 503,
       "when": "trading/RPC not available in this (DB-only) deployment (error.code=no_rpc)"
      }
     ],
     "notes": "Idempotency-Key header supported (strongly recommended for on-chain sends). Returns 503 on DB-only deployments lacking an executor/RPC.",
     "id": "wallet-withdraw"
    },
    {
     "method": "DELETE",
     "path": "/v1/wallet/{id}",
     "name": "Delete wallet",
     "summary": "Deletes a wallet by ID for the tenant user. Refuses to delete the user's last remaining wallet.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "ID of the wallet to delete (must belong to the caller)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"deleted_wallet_id\":12}}",
     "responseNotes": "On success, data.deleted_wallet_id echoes the removed wallet id. Deletion is ownership-scoped to the caller.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 409,
       "when": "cannot delete the last wallet (error.code=last_wallet)"
      },
      {
       "code": 500,
       "when": "database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported.",
     "id": "wallet-delete"
    },
    {
     "method": "POST",
     "path": "/v1/wallet/migrate",
     "name": "Migrate wallet from legacy app",
     "summary": "Imports a wallet from a legacy app's base58 64-byte secret key, stores it encrypted, and stamps migrated_at=NOW() plus source_app_id (the calling API app) so the terminal can track migrations. Optionally sets it active.",
     "authRequired": true,
     "scope": "wallet",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "secret_key_base58",
       "type": "string",
       "description": "Base58-encoded full 64-byte secret key from the legacy app.",
       "required": true
      },
      {
       "name": "name",
       "type": "string",
       "description": "Display name for the migrated wallet.",
       "required": true
      },
      {
       "name": "set_active",
       "type": "bool",
       "description": "If true, atomically makes this the user's active wallet. Defaults to false.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"id\":15,\"public_key\":\"4dW…\",\"migrated\":true}}",
     "responseNotes": "data.migrated is always true on success; the wallet row is stamped with migrated_at and source_app_id (resolved from the authenticated app, AuthCtx). is_generated is stored as FALSE.",
     "errorCodes": [
      {
       "code": 400,
       "when": "secret does not decode to exactly 64 bytes or is not a valid keypair (error.code=bad_secret)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "API key lacks wallet scope"
      },
      {
       "code": 409,
       "when": "duplicate wallet — public key already exists for this user (error.code=duplicate_wallet)"
      },
      {
       "code": 500,
       "when": "encryption failure (error.code=crypto) or database error (error.code=db)"
      }
     ],
     "notes": "Idempotency-Key header supported. source_app_id is taken from the authenticated API app, not from the request body.",
     "id": "wallet-migrate"
    }
   ]
  },
  {
   "label": "Trading API: Keys & Sessions",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Self-serve API key issuance and wallet sessions. Public surface: a Solana wallet signature is the auth (no bearer key required).",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/keys/challenge",
     "name": "Request key-issuance challenge",
     "summary": "Mints a single-use, TTL-bound nonce and a human-readable message for a Solana wallet to sign (Phantom signMessage). The signed message later proves wallet ownership for self-serve key issuance/login. Public — no bearer auth; the wallet signature is the auth. Per-IP rate limited and optionally allowlist-gated.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Base58 Solana wallet address to issue/own the key.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"wallet\":\"<base58-pubkey>\",\"nonce\":\"a1b2c3...\",\"message\":\"Stryke API — prove wallet ownership\\n\\nWallet: <wallet>\\nNonce: <nonce>\\nIssued: <rfc3339>\\n\\nSigning authorizes Stryke to issue an API key to this wallet.\\nIt does NOT approve any transaction, transfer, or spend.\",\"expires_in_secs\":600}}",
     "responseNotes": "data.nonce + data.message are echoed back; the client must sign the EXACT message bytes. data.expires_in_secs is the challenge TTL (a platform setting, default 600). The nonce is NOT consumed here — only on a successful issue/login.",
     "errorCodes": [
      {
       "code": 400,
       "when": "wallet is not a valid base58 32-byte Solana address (bad_wallet)"
      },
      {
       "code": 403,
       "when": "self-serve issuance disabled (self_serve_disabled) or wallet not on allowlist (wallet_not_allowed)"
      },
      {
       "code": 429,
       "when": "per-IP rate limit exceeded (rate_limited)"
      },
      {
       "code": 500,
       "when": "challenge could not be stored (challenge_store_failed)"
      }
     ],
     "notes": "Public surface — gated by a platform setting. Per-IP token bucket (a platform setting / a platform setting) using X-Real-IP (preferred) or X-Forwarded-For.",
     "id": "keys-challenge"
    },
    {
     "method": "POST",
     "path": "/v1/keys/issue",
     "name": "Issue (or rotate) API key via signed challenge",
     "summary": "Verifies the ed25519 signature over the exact challenge message, atomically consumes the single-use nonce, then issues a fresh API key bound to the wallet — or rotates the wallet's existing key (one key per wallet). The full key is returned ONCE; an encrypted recoverable copy is stored so it can be re-revealed later via the session. Also returns a wallet session token. Public — the wallet signature is the auth.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Base58 Solana wallet address; must match the challenge's wallet.",
       "required": true
      },
      {
       "name": "nonce",
       "type": "string",
       "description": "Nonce returned by /v1/keys/challenge.",
       "required": true
      },
      {
       "name": "signature",
       "type": "string",
       "description": "ed25519 signature over the challenge message — base64 (preferred) or base58.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"key\":\"rt_live_...\",\"key_prefix\":\"rt_live_ab12\",\"rotated\":false,\"scopes\":[\"read\",\"trade\"],\"tier\":\"free\",\"credits_monthly\":10000,\"rate_per_min\":120,\"fee_bps\":100,\"session_token\":\"<b64url>.<b64url>\",\"session_expires_at\":1790000000}}",
     "responseNotes": "data.key is the full secret, shown ONCE. rotated=false means newly created, true means an existing key was rotated. scopes/tier/credits_monthly/rate_per_min/fee_bps come from SelfServeConfig env. session_token (HMAC-signed, default 7-day TTL) lets the wallet reveal/reset without re-signing; session_expires_at is a unix-seconds timestamp.",
     "errorCodes": [
      {
       "code": 400,
       "when": "challenge not found/used/expired (challenge_invalid) or wallet doesn't match challenge (wallet_mismatch)"
      },
      {
       "code": 401,
       "when": "signature does not match wallet/message (bad_signature)"
      },
      {
       "code": 403,
       "when": "issuance disabled (self_serve_disabled), wallet not on allowlist (wallet_not_allowed), or the wallet's key was admin-disabled (key_disabled)"
      },
      {
       "code": 429,
       "when": "per-IP rate limit exceeded (rate_limited)"
      },
      {
       "code": 500,
       "when": "challenge lookup/keygen/encrypt/issue failure (challenge_lookup_failed, keygen, encrypt, issue_failed)"
      }
     ],
     "notes": "Public surface — gated by a platform setting. Signature is verified BEFORE the nonce is consumed (a forged sig cannot burn a pending challenge); the nonce is consumed atomically only after verify (single-use, replay-safe).",
     "id": "keys-issue"
    },
    {
     "method": "POST",
     "path": "/v1/keys/login",
     "name": "Wallet login (start session)",
     "summary": "Proves wallet ownership once via a signed challenge and returns a session token (default 7 days) without issuing a key. Reports whether the wallet already has a key and whether it is revealable, so a portal can show Reveal/Reset vs Generate. Public — the wallet signature is the auth.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Base58 Solana wallet address; must match the challenge's wallet.",
       "required": true
      },
      {
       "name": "nonce",
       "type": "string",
       "description": "Nonce returned by /v1/keys/challenge.",
       "required": true
      },
      {
       "name": "signature",
       "type": "string",
       "description": "ed25519 signature over the challenge message — base64 or base58.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"session_token\":\"<b64url>.<b64url>\",\"session_expires_at\":1790000000,\"has_key\":true,\"key_prefix\":\"rt_live_ab12\",\"revealable\":true}}",
     "responseNotes": "session_token (HMAC-signed, a platform setting, default 7d) + session_expires_at (unix secs). has_key = wallet already has an enabled key. key_prefix is null if none. revealable = an encrypted copy exists AND the key is enabled (older keys predating self-reveal are not revealable).",
     "errorCodes": [
      {
       "code": 400,
       "when": "challenge not found/used/expired (challenge_invalid) or wallet doesn't match challenge (wallet_mismatch)"
      },
      {
       "code": 401,
       "when": "signature does not match wallet/message (bad_signature)"
      },
      {
       "code": 403,
       "when": "issuance disabled (self_serve_disabled) or wallet not on allowlist (wallet_not_allowed)"
      },
      {
       "code": 429,
       "when": "per-IP rate limit exceeded (rate_limited)"
      },
      {
       "code": 500,
       "when": "challenge lookup failure (challenge_lookup_failed)"
      }
     ],
     "notes": "Public surface — gated by a platform setting. Consumes the nonce (single-use) after verifying the signature.",
     "id": "keys-login"
    },
    {
     "method": "POST",
     "path": "/v1/keys/reveal",
     "name": "Reveal full API key (session)",
     "summary": "Returns the FULL API key for the session wallet, decrypted server-side from the stored recoverable copy. Requires a valid session token (obtained via /v1/keys/issue or /v1/keys/login) — no new signature needed. Public route; the session token is the auth.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "Wallet session token from /v1/keys/login or /v1/keys/issue.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"key\":\"rt_live_...\",\"key_prefix\":\"rt_live_ab12\"}}",
     "responseNotes": "data.key is the decrypted full secret; key_prefix is its public prefix.",
     "errorCodes": [
      {
       "code": 401,
       "when": "session token expired/invalid (session_invalid)"
      },
      {
       "code": 403,
       "when": "the wallet's key was admin-disabled (key_disabled)"
      },
      {
       "code": 404,
       "when": "no key exists for this wallet yet (no_key)"
      },
      {
       "code": 409,
       "when": "key predates self-reveal — no encrypted copy stored, must reset (not_revealable)"
      },
      {
       "code": 500,
       "when": "DB lookup / decrypt failure (db, decrypt)"
      }
     ],
     "notes": "Public surface. Session token is HMAC-verified (constant-time) against the engine pepper with embedded expiry.",
     "id": "keys-reveal"
    },
    {
     "method": "POST",
     "path": "/v1/keys/reset",
     "name": "Reset (rotate) API key (session)",
     "summary": "Rotates — or first-time generates — the session wallet's API key. The previous key stops working immediately and a fresh full key is returned once. Requires a valid session token; no new signature needed. Public route; the session token is the auth.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "Wallet session token from /v1/keys/login or /v1/keys/issue.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"key\":\"rt_live_...\",\"key_prefix\":\"rt_live_cd34\",\"rotated\":true,\"scopes\":[\"read\",\"trade\"],\"tier\":\"free\",\"credits_monthly\":10000,\"rate_per_min\":120,\"fee_bps\":100}}",
     "responseNotes": "data.key is the new full secret, shown once. rotated=true if a key existed before (false on first-time generate). scopes/tier/credits_monthly/rate_per_min/fee_bps reflect the issued app config. No new session_token is returned (the existing session stays valid).",
     "errorCodes": [
      {
       "code": 401,
       "when": "session token expired/invalid (session_invalid)"
      },
      {
       "code": 403,
       "when": "the wallet's key was admin-disabled (key_disabled)"
      },
      {
       "code": 500,
       "when": "keygen/encrypt/issue failure (keygen, encrypt, issue_failed)"
      }
     ],
     "notes": "Public surface. Session token is HMAC-verified against the engine pepper. Old key is invalidated immediately on rotate.",
     "id": "keys-reset"
    }
   ]
  },
  {
   "label": "Trading API: Billing & Tiers",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Paid tiers with live SOL quotes, on-chain SOL payment redemption (anti-replay), and stake checks.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/tiers",
     "name": "List paid tiers",
     "summary": "Public pricing list: every tier from the operator-editable api_tiers table with USD price, a live SOL-lamports quote (price_usd / live SOL price), credits, rate limits, stake eligibility, plus the payment treasury address. Drives the pricing page so a client can build the exact SOL payment.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"tiers\":[{\"name\":\"pro\",\"display_name\":\"Pro\",\"price_usd\":49.0,\"price_sol_lamports\":326666666,\"credits_monthly\":100000,\"unlimited\":false,\"rate_per_min\":120,\"stake_min\":0,\"stake_eligible\":false,\"window_days\":30}],\"pay_currency\":\"sol\",\"sol_price_usd\":150.0,\"treasury\":\"4xK...treasuryPubkey\",\"stake_rail_enabled\":true}}",
     "responseNotes": "data.tiers[]: name (tier id), display_name, price_usd, price_sol_lamports (null when price is 0 or SOL price unavailable), credits_monthly (null = unlimited), unlimited (credits_monthly is null), rate_per_min, stake_min (raw $STRYKE units), stake_eligible (stake_min > 0), window_days. Top level: pay_currency always \"sol\", sol_price_usd (null if unavailable), treasury (null if unconfigured), stake_rail_enabled (whether STRYKE_MINT is set).",
     "errorCodes": [
      {
       "code": 500,
       "when": "db error reading tiers (error code \"db\")"
      }
     ],
     "notes": "Fully public — no auth, no session. Pricing is DB-driven (no rebuild to change).",
     "id": "billing-tiers"
    },
    {
     "method": "POST",
     "path": "/v1/billing/status",
     "name": "Billing status for session key",
     "summary": "Returns the current tier, credit quota/usage, rate limit, and tier expiry for the API key owned by the wallet behind the session token. Resolves session -> wallet -> self-serve app id in-handler.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "Wallet session token from POST /v1/keys/login. Verified server-side (HMAC) to resolve the owner wallet and its self-serve API key.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"tier\":\"pro\",\"credits_monthly\":100000,\"credits_used\":42,\"credits_remaining\":99958,\"unlimited\":false,\"rate_per_min\":120,\"tier_expires_at\":\"2026-07-26T00:00:00+00:00\"}}",
     "responseNotes": "data.tier (nullable), credits_monthly (null = unlimited), credits_used, credits_remaining (null when unlimited; else max(monthly-used,0)), unlimited (credits_monthly is null), rate_per_min, tier_expires_at (RFC3339, null if no expiry).",
     "errorCodes": [
      {
       "code": 401,
       "when": "session expired or invalid (code session_invalid)"
      },
      {
       "code": 403,
       "when": "this wallet's API key is disabled (code key_disabled)"
      },
      {
       "code": 404,
       "when": "no API key for this wallet (code no_key), or app row not found"
      },
      {
       "code": 500,
       "when": "db error (code db)"
      }
     ],
     "notes": "Auth is the wallet signature session, not an X-API-Key. Generate a key via /v1/keys/* first.",
     "id": "billing-status"
    },
    {
     "method": "POST",
     "path": "/v1/billing/pay",
     "name": "Pay (SOL) and apply tier",
     "summary": "Verifies an on-chain SOL payment and applies the chosen paid tier to the session wallet's API key. Confirms the tx succeeded, was sent from the session wallet (fee payer / account index 0), and credited the treasury >= the tier price (priced in USD, converted at live SOL price with a drift tolerance). Records the signature single-use (atomic redeem) so it can't be replayed.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "Wallet session token from POST /v1/keys/login; binds the payment to the owner wallet.",
       "required": true
      },
      {
       "name": "tier",
       "type": "string",
       "description": "Tier name to purchase (must be a paid tier with price_usd > 0).",
       "required": true
      },
      {
       "name": "currency",
       "type": "string",
       "description": "Payment currency. Defaults to \"sol\"; only \"sol\" is accepted (USDC reserved for later).",
       "required": false
      },
      {
       "name": "tx_signature",
       "type": "string",
       "description": "Signature of the on-chain SOL transfer to the treasury. Must be confirmed, successful, sent from the session wallet, and credit the treasury >= the (tolerance-adjusted) tier price.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"tier\":\"pro\",\"credits_monthly\":100000,\"rate_per_min\":120,\"tier_expires_at\":\"2026-07-26T00:00:00+00:00\",\"paid_lamports\":326700000}}",
     "responseNotes": "data.tier (applied tier name), credits_monthly (null = unlimited), rate_per_min, tier_expires_at (RFC3339, end of tier window), paid_lamports (lamports the treasury actually received in the verified tx).",
     "errorCodes": [
      {
       "code": 401,
       "when": "session expired or invalid (session_invalid)"
      },
      {
       "code": 403,
       "when": "this wallet's API key is disabled (key_disabled)"
      },
      {
       "code": 404,
       "when": "no API key for this wallet (no_key)"
      },
      {
       "code": 400,
       "when": "currency != sol (unsupported_currency); tier is free (free_tier); unknown tier (unknown_tier); invalid session wallet (bad_wallet); payment could not be verified on-chain — tx failed, wrong payer, treasury not credited, or underpaid (payment_unverified)"
      },
      {
       "code": 402,
       "when": "(not used here)"
      },
      {
       "code": 409,
       "when": "this tx_signature was already redeemed (payment_already_used)"
      },
      {
       "code": 503,
       "when": "billing treasury not configured (billing_unconfigured); RPC not available in this deployment (no_rpc); SOL price unavailable (no_sol_price)"
      },
      {
       "code": 500,
       "when": "db error (db) or tier-apply/redeem failure (redeem_failed)"
      }
     ],
     "notes": "Signature recording + tier apply are one atomic DB tx (failed apply rolls back, no burned signature). Drift tolerance from BILLING_SOL_TOLERANCE_BPS (default 300bps, clamped <= 5000bps).",
     "id": "billing-pay"
    },
    {
     "method": "POST",
     "path": "/v1/billing/stake-check",
     "name": "Stake-check ($STRYKE) tier grant",
     "summary": "Checks the session wallet's $STRYKE balance (held in its ATA) and grants the highest-priced tier whose stake_min it meets. Re-checkable; does not reset the credit window unless the tier actually changes, and the tier lapses at window end if the holder drops below the threshold.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "Wallet session token from POST /v1/keys/login; identifies the wallet whose $STRYKE balance is checked.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"tier\":\"pro\",\"stryke_balance\":5000000,\"credits_monthly\":100000,\"rate_per_min\":120,\"tier_expires_at\":\"2026-07-26T00:00:00+00:00\"}}",
     "responseNotes": "data.tier (granted tier name), stryke_balance (raw $STRYKE units in the wallet's ATA), credits_monthly (null = unlimited), rate_per_min, tier_expires_at (RFC3339, end of window). Each grant is audited with a synthetic stake:<wallet>:<tier>:<micros> signature.",
     "errorCodes": [
      {
       "code": 401,
       "when": "session expired or invalid (session_invalid)"
      },
      {
       "code": 403,
       "when": "this wallet's API key is disabled (key_disabled)"
      },
      {
       "code": 404,
       "when": "no API key for this wallet (no_key)"
      },
      {
       "code": 400,
       "when": "invalid wallet (bad_wallet)"
      },
      {
       "code": 402,
       "when": "$STRYKE balance below every stakeable tier's stake_min (insufficient_stake)"
      },
      {
       "code": 503,
       "when": "$STRYKE stake rail not enabled / STRYKE_MINT unset (stake_disabled); RPC not available (no_rpc)"
      },
      {
       "code": 500,
       "when": "db error reading tiers (db) or tier-apply failure (apply_failed)"
      }
     ],
     "notes": "Re-checks call apply_tier with force_reset=false so spamming stake-check cannot zero credits_used. Only counts ATA-held $STRYKE.",
     "id": "billing-stake-check"
    }
   ]
  },
  {
   "label": "Trading API: Token Verification",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). The paid 'Verified by Stryke' token badge — status reads (public) and badge purchase.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/tokens/verified",
     "name": "List verified tokens",
     "summary": "Public list of all mints that currently hold an active paid 'Verified by Stryke' badge. Reads the verified-token table directly (the deepscan engine uses this as a warm cache); always works regardless of whether badge granting is enabled.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"verified\":[{\"mint\":\"So11111111111111111111111111111111111111112\",\"since\":\"2026-06-25T12:00:00+00:00\",\"score_at_verify\":86}],\"count\":1}}",
     "responseNotes": "data.verified[] = active verified mints; each has mint, since (RFC3339 verified_at), score_at_verify (Deep Scan score at grant time). data.count = number of entries.",
     "errorCodes": [
      {
       "code": 500,
       "when": "database error reading verified set"
      }
     ],
     "notes": "Public read — no X-API-Key or wallet session needed. Reports the table even when a platform setting is off.",
     "id": "tokens-verified-list"
    },
    {
     "method": "GET",
     "path": "/v1/tokens/{mint}/verification",
     "name": "Get token verification status",
     "summary": "Public per-token verification lookup. Returns whether a single mint holds an active 'Verified by Stryke' badge; the deepscan engine polls this (fail-open, cached) to decide whether to render the badge. Score-neutral — never affects the token's safety score.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address to look up (trimmed)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"verified\":true,\"status\":\"active\",\"mint\":\"So11111111111111111111111111111111111111112\",\"since\":\"2026-06-25T12:00:00+00:00\",\"score_at_verify\":86}}",
     "responseNotes": "When active: verified=true, status=\"active\", mint, since (RFC3339), score_at_verify. When not verified (no row or non-active): verified=false, status=\"none\", mint only.",
     "errorCodes": [
      {
       "code": 500,
       "when": "database error reading verification row"
      }
     ],
     "notes": "Public read — no auth. Returns verified=false/status=none for unknown or inactive mints rather than 404.",
     "id": "tokens-verification-get"
    },
    {
     "method": "POST",
     "path": "/v1/tokens/verify",
     "name": "Verify token (grant badge)",
     "summary": "Wallet-session + on-chain SOL payment + Deep Scan score gate that grants a paid 'Verified by Stryke' badge to a mint. Requires a valid HMAC wallet session, a confirmed SOL payment from that wallet to the verify/billing treasury (live SOL pricing with drift tolerance), and a current Deep Scan score >= the configured bar. Atomic grant with anti-replay (one tx signature can verify exactly one token). Revenue routes to the trEnD treasury.",
     "authRequired": false,
     "scope": "public (wallet sig)",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "session_token",
       "type": "string",
       "description": "HMAC wallet session token from the keys/billing wallet-sig flow; identifies the paying wallet.",
       "required": true
      },
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address to verify (must be a valid pubkey).",
       "required": true
      },
      {
       "name": "tx_signature",
       "type": "string",
       "description": "Signature of the on-chain SOL payment tx (payer must equal the session wallet; treasury must receive >= the SOL-priced fee minus tolerance). Single-use.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"verified\":true,\"mint\":\"So11111111111111111111111111111111111111112\",\"score_at_verify\":86,\"paid_lamports\":12345678}}",
     "responseNotes": "On success: verified=true, mint, score_at_verify (Deep Scan score that cleared the gate), paid_lamports (lamports the treasury actually received). Errors use {success:false, error:{code,message}}.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid mint (bad_mint), invalid session wallet (bad_wallet), or payment could not be verified — wrong payer/treasury/amount or bad signature (payment_unverified)"
      },
      {
       "code": 401,
       "when": "session expired or invalid (session_invalid)"
      },
      {
       "code": 422,
       "when": "token scored below the required bar (score_too_low) — not charged for the badge"
      },
      {
       "code": 409,
       "when": "the tx signature was already redeemed for a verification (payment_already_used)"
      },
      {
       "code": 502,
       "when": "Deep Scan engine unreachable / could not score the token; gate fails closed (scan_unavailable)"
      },
      {
       "code": 503,
       "when": "feature disabled a platform setting off (verify_disabled), treasury unconfigured (verify_unconfigured), price unset VERIFY_PRICE_USD<=0 (verify_unpriced), no RPC in deployment (no_rpc), or SOL price unavailable (no_sol_price)"
      },
      {
       "code": 500,
       "when": "database error during atomic grant (verify_failed)"
      }
     ],
     "notes": "Entire grant path is gated behind a platform setting (default OFF) — ships inert until the operator enables it after on-chain testing. Badge is score-neutral: it never alters the token's Deep Scan score/grade/flags. Auto-revoked later by a periodic sweep if the score drops below the bar. Operator knobs (env): VERIFY_MIN_SCORE (default 80), VERIFY_PRICE_USD, VERIFY_SOL_TOLERANCE_BPS (default 300, max 5000), VERIFY_TREASURY (else billing treasury), a platform setting (default 55), the platform config (score-gate auth; fails closed if unset).",
     "id": "tokens-verify"
    }
   ]
  },
  {
   "label": "Trading API: Quests",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Multi-tenant quest / engagement campaigns, tasks, verification, status, and leaderboards. quests scope.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/quests/projects",
     "name": "Create quest project",
     "summary": "Creates a quest project in the Node quests engine. Thin metered proxy: the Rust layer authenticates the app key + `quests` scope, charges credits, then forwards the body verbatim with the authenticated `app.id` as `x-stryke-app-id`, which the engine uses to enforce per-tenant ownership (a project's `stryke_app_id` must match the calling app).",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "<engine fields>",
       "type": "object",
       "description": "Free-form JSON forwarded verbatim to the quests engine's POST /quests/projects (e.g. project name/config). Body shape is owned by the Node quests-engine, not this proxy.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined project fields\"}",
     "responseNotes": "2xx body is the quests-engine's JSON forwarded verbatim (Content-Type application/json); field shape is owned by the engine. The `success` boolean drives credit settlement. On proxy faults the Rust layer returns its own envelope: `{\"success\":false,\"error\":{\"code\":...,\"message\":...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks the `quests` scope"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable (quests_engine_unreachable)"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured (quests_unconfigured)"
      }
     ],
     "notes": "Metered proxy to quests-engine (loopback 127.0.0.1:3110). Credit cost = cost_read (default 1). Tenancy enforced engine-side via forwarded x-stryke-app-id.",
     "id": "quests-create-project"
    },
    {
     "method": "POST",
     "path": "/v1/quests/projects/{pid}/credentials",
     "name": "Set project credentials",
     "summary": "Stores per-project verification credentials (e.g. X/Reddit/Discord API secrets) for project `pid` in the quests engine's encrypted vault. Forwards the body verbatim with the authenticated app id; engine rejects if `pid` is not owned by the calling app.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [
      {
       "name": "pid",
       "type": "int",
       "description": "Quest project id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "<engine fields>",
       "type": "object",
       "description": "Free-form JSON of provider credentials forwarded verbatim to POST /quests/projects/{pid}/credentials. Shape owned by the quests-engine.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined fields\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{\"code\":...}}`.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid path param (pid not an integer)"
      },
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope, or project not owned by app (engine-enforced)"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1). Credentials stored in the engine's AES vault, never returned in plaintext.",
     "id": "quests-put-credentials"
    },
    {
     "method": "GET",
     "path": "/v1/quests/projects/{pid}/credentials",
     "name": "Get project credentials",
     "summary": "Returns the (redacted) credential configuration for project `pid` from the quests engine. Proxy forwards the authenticated app id; engine enforces project ownership.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [
      {
       "name": "pid",
       "type": "int",
       "description": "Quest project id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined redacted credential fields\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine; secrets are redacted engine-side. Proxy faults use the `{\"success\":false,\"error\":{...}}` envelope.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid path param (pid not an integer)"
      },
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope, or project not owned by app"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-get-credentials"
    },
    {
     "method": "POST",
     "path": "/v1/quests/campaigns",
     "name": "Create campaign",
     "summary": "Creates a campaign (a grouping of tasks under a project) in the quests engine. Body forwarded verbatim with the authenticated app id for per-tenant ownership.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "<engine fields>",
       "type": "object",
       "description": "Free-form JSON forwarded verbatim to POST /quests/campaigns (e.g. project_id, name, schedule). Shape owned by the quests-engine.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined campaign fields\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope, or referenced project not owned by app"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-create-campaign"
    },
    {
     "method": "POST",
     "path": "/v1/quests/tasks",
     "name": "Create task",
     "summary": "Creates a verifiable task (e.g. follow/retweet/join/on-chain action) under a campaign in the quests engine. Body forwarded verbatim with the authenticated app id.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "<engine fields>",
       "type": "object",
       "description": "Free-form JSON forwarded verbatim to POST /quests/tasks (e.g. campaign_id, type, target, reward). Shape owned by the quests-engine.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined task fields\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope, or referenced campaign/project not owned by app"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-create-task"
    },
    {
     "method": "GET",
     "path": "/v1/quests/tasks",
     "name": "List tasks",
     "summary": "Lists tasks from the quests engine, scoped to the calling app. All query-string params are passed through verbatim (percent-encoded) to the engine's GET /quests/tasks.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [
      {
       "name": "<engine filters>",
       "type": "string",
       "description": "Arbitrary filter params (e.g. campaign_id, project_id, status) forwarded verbatim to the quests-engine. Accepted params are defined by the engine.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined task list\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-list-tasks"
    },
    {
     "method": "POST",
     "path": "/v1/quests/verify",
     "name": "Verify quest task",
     "summary": "Submits a task completion attempt for verification (the billable unit of the Quests API). The engine performs the actual check (X/Reddit/Discord/on-chain). Charge-per-attempt: the credit reservation is kept on any 2xx success:true, including verified:false, since the upstream cost was incurred.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "<engine fields>",
       "type": "object",
       "description": "Free-form JSON forwarded verbatim to POST /quests/verify (e.g. task_id, wallet/user identity, proof). Shape owned by the quests-engine.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"verified\":true,\"...\":\"engine-defined verification result\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine; `verified` reflects whether the task passed. Treated as an EXECUTION path: a 2xx with success:true keeps the credit charge even when verified:false. Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope, or task not owned by app"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_quests_verify (env CREDIT_COST_QUESTS_VERIFY, default 2). Billable per-attempt; the charge is retained on any 2xx success:true response.",
     "id": "quests-verify"
    },
    {
     "method": "GET",
     "path": "/v1/quests/status",
     "name": "Quest status",
     "summary": "Returns task/campaign completion status for a participant from the quests engine, scoped to the calling app. Query params forwarded verbatim.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [
      {
       "name": "<engine filters>",
       "type": "string",
       "description": "Arbitrary params (e.g. wallet/user id, campaign_id, task_id) forwarded verbatim to the quests-engine's GET /quests/status.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined status fields\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-status"
    },
    {
     "method": "GET",
     "path": "/v1/quests/leaderboard",
     "name": "Quest leaderboard",
     "summary": "Returns the participant leaderboard for a campaign/project from the quests engine, scoped to the calling app. Query params forwarded verbatim.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [
      {
       "name": "<engine filters>",
       "type": "string",
       "description": "Arbitrary params (e.g. campaign_id, project_id, limit) forwarded verbatim to the quests-engine's GET /quests/leaderboard.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined leaderboard entries\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope"
      },
      {
       "code": 402,
       "when": "insufficient credits / quota exceeded"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = cost_read (default 1).",
     "id": "quests-leaderboard"
    },
    {
     "method": "GET",
     "path": "/v1/quests/usage",
     "name": "Quests usage",
     "summary": "Returns the quests engine's per-app usage/metering snapshot (verify counts, etc.), scoped to the calling app. Free — not credit-charged (paths ending in /usage cost 0). Query params forwarded verbatim.",
     "authRequired": true,
     "scope": "quests",
     "pathParams": [],
     "queryParams": [
      {
       "name": "<engine filters>",
       "type": "string",
       "description": "Arbitrary params (e.g. window/period) forwarded verbatim to the quests-engine's GET /quests/usage.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"...\":\"engine-defined usage counters\"}",
     "responseNotes": "2xx body forwarded verbatim from the quests-engine (application/json). Proxy faults use `{\"success\":false,\"error\":{...}}`.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid Bearer API key"
      },
      {
       "code": 403,
       "when": "API key lacks `quests` scope"
      },
      {
       "code": 429,
       "when": "per-app rate limit exceeded"
      },
      {
       "code": 502,
       "when": "quests engine unreachable"
      },
      {
       "code": 503,
       "when": "QUESTS_INTERNAL_KEY not configured"
      }
     ],
     "notes": "Credit cost = 0 (free; cost_for_path returns 0 for paths ending in /usage).",
     "id": "quests-usage"
    }
   ]
  },
  {
   "label": "Trading API: Account & Profile",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). User settings, rewards/XP, invite codes, and support tickets. read scope; per-tenant.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/settings",
     "name": "Get user settings",
     "summary": "Returns the full settings dump for the tenant user resolved from the X-User-Ref header (slippage, gas, MEV flags, quick buy/sell presets, PNL card prefs, withdraw address). Per-tenant: the user is resolved from X-User-Ref scoped to the calling app.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"settings\":{\"locale\":\"en\",\"default_buy_amount\":500000000,\"default_slippage_bps\":1500,\"buy_amounts\":[100000000,250000000,500000000,1000000000,2500000000],\"buy_slippage_bps\":1500,\"sell_percents\":[25,50,75,100],\"sell_slippage_bps\":1500,\"priority_fee_lamports\":0,\"gas_cu_limit\":0,\"gas_cu_price\":0,\"gas_tip\":0.0,\"confirm_trades\":true,\"mev_protect_buy\":false,\"mev_protect_sell\":false,\"sell_protection\":false,\"pnl_cards_enabled\":true,\"pnl_cards_hide_losses\":false,\"pnl_cards_hide_amounts\":false,\"pnl_cards_show_qr\":false,\"autosell_profile_id\":null,\"withdraw_address\":null,\"withdraw_address_locked\":false,\"created_at\":\"2026-01-01T00:00:00Z\",\"last_active\":\"2026-06-01T00:00:00Z\"}}",
     "responseNotes": "settings is the UserProfile row: locale; default_buy_amount + default_slippage_bps; buy_amounts (5 lamport presets) + buy_slippage_bps; sell_percents (4 pct presets) + sell_slippage_bps; priority_fee_lamports, gas_cu_limit, gas_cu_price, gas_tip; confirm_trades, mev_protect_buy, mev_protect_sell, sell_protection; pnl_cards_* prefs; autosell_profile_id (nullable); withdraw_address (nullable) + withdraw_address_locked; created_at + last_active timestamps. On the not-found path returns {\"success\":false,\"error\":\"User not found. Send any trade first to auto-create.\"}.",
     "errorCodes": [
      {
       "code": 200,
       "when": "always for logical errors — body carries {\"success\":false,\"error\":\"...\"} (user not found / DB error / missing user)"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key (auth middleware)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope (scope middleware)"
      }
     ],
     "notes": "Requires the X-User-Ref header to identify the tenant user (the user row is auto-created on first trade; until then GET returns success:false 'User not found').",
     "id": "settings-get"
    },
    {
     "method": "PUT",
     "path": "/v1/settings",
     "name": "Update user settings (batch)",
     "summary": "Batch-updates any subset of user settings — only fields present in the JSON body are applied, omitted fields are left unchanged. Each field is validated and written independently; the response reports which fields were updated and any per-field errors. Per-tenant: user resolved from X-User-Ref.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "locale",
       "type": "string",
       "description": "UI locale. Must be one of en, ru, zh-CN, es.",
       "required": false
      },
      {
       "name": "buy_slippage_bps",
       "type": "int",
       "description": "Buy slippage in bps, 0-10000.",
       "required": false
      },
      {
       "name": "sell_slippage_bps",
       "type": "int",
       "description": "Sell slippage in bps, 0-10000.",
       "required": false
      },
      {
       "name": "priority_fee_lamports",
       "type": "int",
       "description": "Priority fee in lamports, must be >= 0.",
       "required": false
      },
      {
       "name": "gas_cu_price",
       "type": "int",
       "description": "Compute-unit price, must be >= 0.",
       "required": false
      },
      {
       "name": "gas_cu_limit",
       "type": "int",
       "description": "Compute-unit limit, must be >= 0 (written via raw SQL).",
       "required": false
      },
      {
       "name": "gas_tip",
       "type": "number",
       "description": "Gas tip in SOL, must be >= 0 (written via raw SQL).",
       "required": false
      },
      {
       "name": "mev_protect_buy",
       "type": "bool",
       "description": "Enable MEV protection on buys.",
       "required": false
      },
      {
       "name": "mev_protect_sell",
       "type": "bool",
       "description": "Enable MEV protection on sells.",
       "required": false
      },
      {
       "name": "sell_protection",
       "type": "bool",
       "description": "Enable sell protection.",
       "required": false
      },
      {
       "name": "confirm_trades",
       "type": "bool",
       "description": "Require trade confirmation dialog.",
       "required": false
      },
      {
       "name": "pnl_cards_enabled",
       "type": "bool",
       "description": "Enable PNL cards.",
       "required": false
      },
      {
       "name": "pnl_cards_hide_losses",
       "type": "bool",
       "description": "Hide losing trades on PNL cards.",
       "required": false
      },
      {
       "name": "pnl_cards_hide_amounts",
       "type": "bool",
       "description": "Hide amounts on PNL cards.",
       "required": false
      },
      {
       "name": "pnl_cards_show_qr",
       "type": "bool",
       "description": "Show QR code on PNL cards.",
       "required": false
      },
      {
       "name": "autosell_profile_id",
       "type": "int|null",
       "description": "Autosell profile id. Nested-optional: omit = skip, null = clear, value = set.",
       "required": false
      },
      {
       "name": "withdraw_address",
       "type": "string|null",
       "description": "Withdraw destination. Nested-optional: omit = skip, null or empty string = clear, value = set.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"updated\":[\"locale\",\"buy_slippage_bps\",\"mev_protect_buy\"]}",
     "responseNotes": "On full success: {\"success\":true,\"updated\":[<field names applied>]}. If any field failed validation or its DB write errored: {\"success\":false,\"updated\":[<fields that succeeded>],\"errors\":[<per-field error strings>]} — partial writes are NOT rolled back (already-applied fields persist). Validation errors include 'Invalid locale...', 'buy_slippage_bps must be 0-10000', 'priority_fee_lamports must be >= 0', etc.",
     "errorCodes": [
      {
       "code": 200,
       "when": "always for handler logic — body carries success:true/false with updated[] + errors[]"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson extractor rejects before handler)"
      }
     ],
     "notes": "Requires X-User-Ref header (user resolved from RequestCtx). autosell_profile_id and withdraw_address use nested-optional semantics (omit vs null vs value).",
     "id": "settings-update"
    },
    {
     "method": "PUT",
     "path": "/v1/settings/buy-amounts",
     "name": "Set quick-buy amounts",
     "summary": "Sets the 5 quick-buy preset amounts (in lamports) for the tenant user. Requires exactly 5 positive values; writes them to buy_amount_1..5 (1-based index). Per-tenant: user resolved from X-User-Ref.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "amounts",
       "type": "int[]",
       "description": "Exactly 5 buy amounts in lamports, each > 0.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"buy_amounts\":[100000000,250000000,500000000,1000000000,2500000000]}",
     "responseNotes": "On success echoes back the saved buy_amounts array. On validation/DB failure returns {\"success\":false,\"error\":\"<message>\"} (e.g. 'Expected exactly 5 buy amounts in lamports', 'Amount at index N must be > 0', 'Failed to set buy_amount_N:...').",
     "errorCodes": [
      {
       "code": 200,
       "when": "validation/DB failures returned in body as {\"success\":false,\"error\":...}"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson extractor)"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "settings-set-buy-amounts"
    },
    {
     "method": "PUT",
     "path": "/v1/settings/sell-percents",
     "name": "Set quick-sell percentages",
     "summary": "Sets the 4 quick-sell preset percentages for the tenant user. Requires exactly 4 values each in 1-100; writes them to sell_percent_1..4 (1-based index). Per-tenant: user resolved from X-User-Ref.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "percents",
       "type": "int[]",
       "description": "Exactly 4 sell percentages, each 1-100.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"sell_percents\":[25,50,75,100]}",
     "responseNotes": "On success echoes back the saved sell_percents array. On validation/DB failure returns {\"success\":false,\"error\":\"<message>\"} (e.g. 'Expected exactly 4 sell percentages', 'Percent at index N must be 1-100', 'Failed to set sell_percent_N:...').",
     "errorCodes": [
      {
       "code": 200,
       "when": "validation/DB failures returned in body as {\"success\":false,\"error\":...}"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson extractor)"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "settings-set-sell-percents"
    },
    {
     "method": "POST",
     "path": "/v1/settings/reset-buy",
     "name": "Reset buy settings",
     "summary": "Resets the tenant user's buy settings (quick-buy amounts, default buy amount, buy slippage) to system defaults. Per-tenant: user resolved from X-User-Ref.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Buy settings reset to defaults\",\"defaults\":{\"buy_amounts\":[100000000,250000000,500000000,1000000000,2500000000],\"default_buy_amount\":500000000,\"buy_slippage_bps\":1500}}",
     "responseNotes": "On success returns the applied defaults: buy_amounts [0.1, 0.25, 0.5, 1.0, 2.5 SOL in lamports], default_buy_amount 500000000, buy_slippage_bps 1500. On failure returns {\"success\":false,\"error\":\"Failed to reset buy settings:...\"}.",
     "errorCodes": [
      {
       "code": 200,
       "when": "DB failures returned in body as {\"success\":false,\"error\":...}"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      }
     ],
     "notes": "Requires X-User-Ref header (read via require_user from request extensions). No request body.",
     "id": "settings-reset-buy"
    },
    {
     "method": "POST",
     "path": "/v1/settings/reset-sell",
     "name": "Reset sell settings",
     "summary": "Resets the tenant user's sell settings (quick-sell percentages and sell slippage) to system defaults. Per-tenant: user resolved from X-User-Ref.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Sell settings reset to defaults\",\"defaults\":{\"sell_percents\":[25,50,75,100],\"sell_slippage_bps\":1500}}",
     "responseNotes": "On success returns the applied defaults: sell_percents [25,50,75,100], sell_slippage_bps 1500. On failure returns {\"success\":false,\"error\":\"Failed to reset sell settings:...\"}.",
     "errorCodes": [
      {
       "code": 200,
       "when": "DB failures returned in body as {\"success\":false,\"error\":...}"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      }
     ],
     "notes": "Requires X-User-Ref header (read via require_user from request extensions). No request body.",
     "id": "settings-reset-sell"
    },
    {
     "method": "GET",
     "path": "/v1/rewards/profile",
     "name": "Get rewards profile",
     "summary": "Returns the calling user's XP, level, daily streak, referral code, and unclaimed cashback/referral balances, plus computed progress toward the next level. Scoped to the authenticated user (resolved from X-User-Ref); auto-creates a rewards row on first access.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"current_xp\":1250,\"total_xp_earned\":3400,\"level\":4,\"streak\":7,\"last_trade_at\":\"2026-06-25\",\"referral_code\":\"AB12CD\",\"cashback_balance_lamports\":150000,\"referral_balance_lamports\":80000,\"next_level_xp\":2000,\"progress_pct\":62.5}}",
     "responseNotes": "data.current_xp/total_xp_earned = XP counters; level = current_level; streak = daily_streak; last_trade_at = last trade date (string or null); referral_code = user's invite code; cashback_balance_lamports/referral_balance_lamports = unclaimed amounts in lamports; next_level_xp = XP threshold for next level (0 at max level); progress_pct = 0-100 progress within current level.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user ref — require_user fails (error_response envelope, HTTP 200-style success:false body)"
      },
      {
       "code": 503,
       "when": "DB error fetching user rewards or reward levels"
      }
     ],
     "notes": "Requires X-User-Ref tenancy header (attach_request_ctx). On any DB/user error returns the standard error_response JSON envelope ({success:false,error:...}).",
     "id": "rewards-profile"
    },
    {
     "method": "GET",
     "path": "/v1/rewards/achievements",
     "name": "List achievements",
     "summary": "Returns the full achievement catalog merged with the calling user's unlock status and unlock timestamps. Scoped to the authenticated user (resolved from X-User-Ref).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"total\":24,\"unlocked\":5,\"achievements\":[{\"id\":1,\"name\":\"First Trade\",\"description\":\"Complete your first trade\",\"icon\":\"star\",\"xp_reward\":100,\"category\":\"trading\",\"unlocked\":true,\"unlocked_at\":1719273600}]}}",
     "responseNotes": "data.total = catalog size; data.unlocked = count unlocked by user; achievements[] = every achievement with id/name/description/icon/xp_reward/category plus unlocked (bool) and unlocked_at (unix epoch i64 or null).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user ref — require_user fails (error_response envelope)"
      },
      {
       "code": 503,
       "when": "DB error fetching achievements or user achievements"
      }
     ],
     "notes": "Requires X-User-Ref tenancy header. DB/user errors return the error_response JSON envelope.",
     "id": "rewards-achievements"
    },
    {
     "method": "GET",
     "path": "/v1/rewards/referral-stats",
     "name": "Get referral stats",
     "summary": "Returns the calling user's multi-level referral tree stats (per-depth commission earned and user counts) plus the direct (depth-1) referral count. Scoped to the authenticated user.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"direct_referrals\":3,\"stats\":[{\"depth\":1,\"total_commission_lamports\":420000,\"total_trades\":3}]}}",
     "responseNotes": "data.direct_referrals = count of direct referrals; stats[] = per referral-tree depth: depth (level below user), total_commission_lamports (sum earned at that depth, lamports), total_trades (user_count at that depth).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user ref — require_user fails (error_response envelope)"
      },
      {
       "code": 503,
       "when": "DB error fetching referral stats or counting direct referrals"
      }
     ],
     "notes": "Requires X-User-Ref tenancy header. DB/user errors return the error_response JSON envelope.",
     "id": "rewards-referral-stats"
    },
    {
     "method": "GET",
     "path": "/v1/rewards/leaderboard",
     "name": "XP leaderboard",
     "summary": "Returns the top users ranked by XP descending. PII-safe: raw telegram_id is never exposed — each entry gets a rank-derived anon handle (\"anon-1\", \"anon-2\",...). App-wide (not per-user).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max entries to return; defaults to 10, capped at 100.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"leaderboard\":[{\"rank\":1,\"handle\":\"anon-1\",\"xp\":99000,\"level\":12,\"streak\":30}]}}",
     "responseNotes": "data.leaderboard[] sorted by xp DESC: rank (1-based), handle (rank-derived anon-N, NOT a resolvable id — v0.7.265 PII fix), xp (current_xp), level (current_level), streak (daily_streak).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "DB query error on user_rewards"
      }
     ],
     "notes": "No X-User-Ref required — global leaderboard. Does not call require_user. DB errors return the error_response JSON envelope.",
     "id": "rewards-leaderboard"
    },
    {
     "method": "GET",
     "path": "/v1/rewards/levels",
     "name": "List level definitions",
     "summary": "Returns all reward level definitions with their XP thresholds and names. Static catalog — no user context.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":[{\"level\":1,\"xp_required\":0,\"name\":\"Rookie\"},{\"level\":2,\"xp_required\":500,\"name\":\"Trader\"}]}",
     "responseNotes": "data[] = each level: level (number), xp_required (cumulative XP threshold to reach it), name (level title).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "DB error fetching reward levels"
      }
     ],
     "notes": "No X-User-Ref required — static level catalog. Does not call require_user. DB errors return the error_response JSON envelope.",
     "id": "rewards-levels"
    },
    {
     "method": "POST",
     "path": "/v1/invite/create",
     "name": "Create invite code",
     "summary": "Creates a new invite/referral code owned by the calling tenant user (resolved from X-User-Ref via the request context). The code, optional label, max_uses (default 1), and optional expiry are persisted under created_by = caller.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "code",
       "type": "string",
       "description": "The invite code string to create. Required and must be non-empty.",
       "required": true
      },
      {
       "name": "label",
       "type": "string",
       "description": "Optional human label for the code.",
       "required": false
      },
      {
       "name": "max_uses",
       "type": "int",
       "description": "Max redemptions allowed. Defaults to 1 if omitted.",
       "required": false
      },
      {
       "name": "expires_at",
       "type": "int",
       "description": "Optional Unix epoch (seconds) expiry timestamp.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"code\":\"ALPHA123\",\"label\":\"launch\",\"max_uses\":1,\"expires_at\":1735689600,\"created_by\":796611972}}",
     "responseNotes": "data echoes the created code, label, max_uses, expires_at, and created_by (the caller's resolved user id). Errors return HTTP 200 with {\"success\":false,\"error\":\"...\"}: empty code -> 'code is required and cannot be empty'; DB failure -> 'Failed to create invite code:...'; unresolved user -> 'missing user'.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson rejection)"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "Application-level failures (empty code, missing user, DB error) are returned as HTTP 200 with success:false rather than a non-2xx status.",
     "id": "invite-create"
    },
    {
     "method": "GET",
     "path": "/v1/invite/list",
     "name": "List my invite codes",
     "summary": "Lists the CALLER's invite codes with usage stats, scoped to created_by = caller (v0.7.221 fix; previously leaked every code system-wide). Returns each code with label, counts, active flag, and timestamps.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"total\":1,\"codes\":[{\"code\":\"ALPHA123\",\"label\":\"launch\",\"created_by\":796611972,\"max_uses\":1,\"use_count\":0,\"is_active\":true,\"expires_at\":null,\"created_at\":1735689600}]}}",
     "responseNotes": "data.total is the number of codes returned; data.codes[] each contains code, label, created_by, max_uses, use_count, is_active, expires_at, created_at. Only codes the caller created are returned.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "App-level failures return HTTP 200 with success:false ('missing user' or 'Failed to list invite codes:...').",
     "id": "invite-list"
    },
    {
     "method": "POST",
     "path": "/v1/invite/use",
     "name": "Redeem invite code",
     "summary": "Validates and consumes an invite code for the calling user, then marks the user alpha-approved. Validation + consumption is atomic; on success the caller gains alpha access.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "code",
       "type": "string",
       "description": "The invite code to redeem. Required and must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"code\":\"ALPHA123\",\"alpha_approved\":true}}",
     "responseNotes": "data echoes the redeemed code and alpha_approved:true. The caller's telegram_id is deliberately NOT echoed (v0.7.223 fix #12 — avoids a confirmation oracle). Errors (HTTP 200, success:false): empty code; 'Invalid, expired, or exhausted invite code' when validation fails; 'Code was consumed but failed to approve user:...' if approval fails post-consume; 'missing user'.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson rejection)"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "Invalid/expired/exhausted codes return HTTP 200 with success:false, not a 4xx.",
     "id": "invite-use"
    },
    {
     "method": "POST",
     "path": "/v1/invite/revoke",
     "name": "Revoke invite code",
     "summary": "Deactivates one of the CALLER's invite codes, scoped via WHERE created_by = caller (v0.7.221 fix). Non-owners get a generic 'not found or already revoked' message regardless of whether the code exists.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "code",
       "type": "string",
       "description": "The invite code to revoke. Required and must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"code\":\"ALPHA123\",\"revoked\":true}}",
     "responseNotes": "On success data.revoked is true. If the code is not owned by the caller or was already revoked, returns HTTP 200 with {\"success\":false,\"error\":\"Invite code not found or already revoked\"}. Other errors: empty code; 'Failed to revoke invite code:...'; 'missing user'.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body (ApiJson rejection)"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "Not-found/not-owned returns HTTP 200 success:false (no 404). Ownership is enforced at the SQL layer.",
     "id": "invite-revoke"
    },
    {
     "method": "GET",
     "path": "/v1/invite/alpha-users",
     "name": "List my codes' alpha redemptions",
     "summary": "Lists recent alpha-approved redemptions of the CALLER's invite codes only (SQL JOIN scoped to creator, v0.7.223 fix #5). telegram_id is stripped from every row (PII leak fix); only the redeeming code and timestamp are returned.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows to return. Defaults to 20, capped at 500.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"total\":1,\"users\":[{\"invited_by_code\":\"ALPHA123\",\"created_at\":1735689600}]}}",
     "responseNotes": "data.total is the row count; data.users[] each has invited_by_code and created_at only — telegram_id is intentionally omitted. Filtered to redemptions of codes the caller created; LIMIT applies after the ownership filter.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "App-level failures return HTTP 200 with success:false ('missing user' or 'Failed to list alpha users:...').",
     "id": "invite-alpha-users"
    },
    {
     "method": "GET",
     "path": "/v1/invite/stats",
     "name": "My invite stats",
     "summary": "Returns aggregate invite stats scoped to the CALLER's codes only (v0.7.221 fix; previously exposed platform-wide user counts and the full code list). No platform-wide counts are exposed.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"your_codes\":3,\"active_codes\":2,\"total_code_uses\":7}}",
     "responseNotes": "data.your_codes = total codes the caller created; active_codes = currently active among them; total_code_uses = sum of redemptions across the caller's codes.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "App-level failures return HTTP 200 with success:false ('missing user' or 'DB error:...').",
     "id": "invite-stats"
    },
    {
     "method": "GET",
     "path": "/v1/invite/check",
     "name": "Check alpha approval",
     "summary": "Checks whether the current user is alpha-approved. Resolves the user id from the request extensions (require_user) rather than the tenancy ctx.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"telegram_id\":796611972,\"alpha_approved\":true}}",
     "responseNotes": "data.telegram_id is the caller's resolved user id and alpha_approved is the boolean approval status. Note: unlike the other invite routes, this one DOES echo telegram_id.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "App-level failures return HTTP 200 with success:false ('missing user' or 'DB error:...').",
     "id": "invite-check"
    },
    {
     "method": "POST",
     "path": "/v1/tickets/create",
     "name": "Create support ticket",
     "summary": "Opens a new support ticket for the authenticated tenant user. The active wallet address (if any) is auto-resolved and attached; username is null under API-key auth.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "message",
       "type": "string",
       "description": "Ticket body. Must be non-empty (trimmed).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"ticket_id\":1234,\"message\":\"My swap failed\"}",
     "responseNotes": "ticket_id is the new ticket's DB id; message echoes the submitted body. User is resolved from the tenancy RequestCtx (X-User-Ref).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user in request context, or message is empty/whitespace, or DB create_ticket fails"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "Idempotency-Key header honored (read_routes does not apply the idempotency layer, but write effect is a single INSERT). user_id derived from tenancy context, not callable cross-user.",
     "id": "tickets-create"
    },
    {
     "method": "GET",
     "path": "/v1/tickets/list",
     "name": "List my tickets",
     "summary": "Returns the calling user's tickets, most recent first (capped at 10 by the DB layer). Scoped to the authenticated telegram_id; never returns other users' tickets.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"tickets\":[{\"id\":1234,\"message\":\"My swap failed\",\"status\":\"open\",\"admin_reply\":null,\"admin_reply_at\":null,\"created_at\":\"2026-06-25T12:00:00Z\"}]}",
     "responseNotes": "count is the number of returned tickets; each ticket has id, message, status (open/replied/closed), admin_reply, admin_reply_at, created_at.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user in request context, or DB query fails"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "id": "tickets-list"
    },
    {
     "method": "GET",
     "path": "/v1/tickets/stats",
     "name": "Ticket counts",
     "summary": "Returns the calling user's ticket counts: total plus per-status buckets (open, replied, closed). All counts are scoped to the caller's telegram_id (platform-wide totals were removed in v0.7.251 to prevent cross-tenant volume leakage).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"stats\":{\"total\":5,\"open\":2,\"replied\":1,\"closed\":2}}",
     "responseNotes": "stats.total is the caller's total ticket count; open/replied/closed are per-status counts for the same user.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user in request context, or a count query fails"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "id": "tickets-stats"
    },
    {
     "method": "GET",
     "path": "/v1/tickets/{id}",
     "name": "Get ticket by id",
     "summary": "Fetches a single ticket by id, ownership-enforced at the SQL layer. A request for another user's ticket returns 'Ticket not found' identically to a nonexistent id (no existence leak).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Ticket id (i64). Must belong to the calling user."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"ticket\":{\"id\":1234,\"telegram_id\":796611972,\"username\":null,\"wallet_address\":\"So1111...\",\"message\":\"My swap failed\",\"status\":\"open\",\"admin_reply\":null,\"admin_reply_at\":null,\"forwarded_message_id\":null,\"created_at\":\"2026-06-25T12:00:00Z\"}}",
     "responseNotes": "ticket includes id, telegram_id, username, wallet_address, message, status, admin_reply, admin_reply_at, forwarded_message_id, created_at.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user in request context, ticket not found / not owned by caller, or DB query fails"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "id": "tickets-get"
    },
    {
     "method": "POST",
     "path": "/v1/tickets/{id}/close",
     "name": "Close ticket",
     "summary": "Closes one of the caller's tickets. Ownership is enforced in the WHERE clause; returns 'Ticket not found' for another user's id and 'already closed' if the caller's ticket is already inactive.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Ticket id (i64) to close. Must belong to the calling user."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"ticket_id\":1234,\"status\":\"closed\"}",
     "responseNotes": "On success returns the closed ticket_id and status 'closed'.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing user in request context, ticket not found / not owned by caller, ticket already closed, or DB update fails"
      },
      {
       "code": 503,
       "when": "database unavailable"
      }
     ],
     "notes": "The former POST /v1/tickets/{id}/reply route was REMOVED in v0.7.220 (admin-reply spoofing vector) and is not in the route table.",
     "id": "tickets-close"
    }
   ]
  },
  {
   "label": "Trading API: Fees, PnL & Positions",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Fee accounting, live and historical PnL, and position/trade history. read scope; per-tenant.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/fees/summary",
     "name": "Fee summary",
     "summary": "Returns the calling app's total platform fees collected with a buy/sell breakdown and effective fee rate. Scoped per-tenant via api_app_users.app_id (platform-wide totals are not exposed).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"grand_total_lamports\":1234567,\"grand_total_sol\":0.001234567,\"total_trade_volume_lamports\":123456789,\"total_trade_volume_sol\":0.123456789,\"effective_fee_rate_bps\":100,\"by_side\":[{\"side\":\"buy\",\"total_lamports\":700000,\"count\":12},{\"side\":\"sell\",\"total_lamports\":534567,\"count\":8}]}}",
     "responseNotes": "data.grand_total_lamports/grand_total_sol = total fees collected; total_trade_volume_lamports/sol = summed trade notional; effective_fee_rate_bps = grand_total/volume*10000 (0 if no volume); by_side = array of {side (trade_side: buy/sell), total_lamports, count}.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "DB error querying fee_ledger (returned as success:false error envelope)"
      }
     ],
     "notes": "Read-scope, tenant-scoped. All fee rows are filtered to users of the calling app.",
     "id": "fees-summary"
    },
    {
     "method": "GET",
     "path": "/v1/fees/by-source",
     "name": "Fees by source",
     "summary": "Returns the calling app's fee revenue broken down by trade source (manual, dca, limit, copy_trade, tg_auto, x_auto, afk, etc.). Scoped to ctx.app_id; the legacy app_id query param is accepted but ignored.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "app_id",
       "type": "int",
       "description": "Accepted for backward compat but IGNORED — scope is always the caller's own app_id.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"sources\":[{\"source\":\"manual\",\"total_lamports\":700000,\"total_sol\":0.0007,\"total_trade_lamports\":70000000,\"total_trade_sol\":0.07,\"count\":12}]}}",
     "responseNotes": "data.sources = array of {source (trade_source), total_lamports/total_sol (fees), total_trade_lamports/total_trade_sol (trade notional), count}, ordered by total_lamports DESC.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "DB error querying fee_ledger (returned as success:false error envelope)"
      }
     ],
     "notes": "Read-scope, tenant-scoped. The ?app_id= param is ignored to prevent cross-tenant fee enumeration.",
     "id": "fees-by-source"
    },
    {
     "method": "GET",
     "path": "/v1/fees/hourly",
     "name": "Fees hourly",
     "summary": "Returns the calling app's fee collection bucketed by hour over a lookback window. Scoped per-tenant via api_app_users.app_id.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "hours",
       "type": "int",
       "description": "Hours to look back (default 24, must be 1–720).",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"hours\":24,\"data\":[{\"hour\":\"2026-06-26T14:00:00+00:00\",\"total_lamports\":52000,\"count\":3}]}}",
     "responseNotes": "data.hours = echoed lookback window; data.data = array of hourly buckets {hour (RFC3339 hour-truncated), total_lamports (fees), count (trades)}, ordered by hour DESC.",
     "errorCodes": [
      {
       "code": 400,
       "when": "hours not in 1–720 (returned as success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      },
      {
       "code": 503,
       "when": "DB error querying fee_ledger (returned as success:false error envelope)"
      }
     ],
     "notes": "Read-scope, tenant-scoped.",
     "id": "fees-hourly"
    },
    {
     "method": "GET",
     "path": "/v1/fees/rates",
     "name": "Fee rates",
     "summary": "Returns the current platform fee BPS rates per trade source and the treasury wallet, read from server environment config. Not tenant-specific (global server config).",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"manual_bps\":100,\"dca_bps\":100,\"limit_bps\":100,\"copy_trade_bps\":100,\"tg_auto_bps\":100,\"x_auto_bps\":100,\"afk_bps\":0,\"treasury\":\"<treasury-pubkey>\"}}",
     "responseNotes": "data fields are per-source fee BPS from env (MANUAL_FEE_BPS/DCA_FEE_BPS/LIMIT_FEE_BPS/COPY_TRADE_FEE_BPS/a platform setting/a platform setting default 100; AFK_FEE_BPS default 0) plus treasury = a platform setting pubkey string.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks read scope"
      }
     ],
     "notes": "Read-scope. Values come from server.env, not per-tenant DB; no DB access so no 503 path.",
     "id": "fees-rates"
    },
    {
     "method": "GET",
     "path": "/v1/pnl/positions",
     "name": "List open positions (PNL)",
     "summary": "Returns all currently open positions for the caller's active wallet, each aggregated from its success trades (total buy/sell amounts, buy/sell counts, average entry price in USD and SOL, total tips). Tenant-scoped: resolves user_id from X-User-Ref to the active wallet_id; returns an empty list if the user has no wallet yet.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"positions\":[{\"token_mint\":\"So111...\",\"token_name\":\"Wrapped SOL\",\"token_symbol\":\"SOL\",\"dex_type\":\"pumpfun\",\"total_buys_lamports\":1000000,\"total_sells_lamports\":0,\"buy_count\":1,\"sell_count\":0,\"avg_entry_price_usd\":0.0001,\"avg_entry_price_sol\":0.0000001,\"first_trade_at\":1719300000,\"last_trade_at\":1719300000,\"source\":\"swap\",\"total_tips_lamports\":50000}]}",
     "responseNotes": "positions[] aggregates trades joined on positions where status='open'. total_buys_lamports = sum of buy input_amount; total_sells_lamports = sum of sell output_amount; avg_entry_price_usd/sol are input-amount-weighted buy prices; first_trade_at is position opened_at, last_trade_at is max trade created_at; source is the position open source (swap/copy_trade/sniper/dca/limit). count = length of positions[].",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 500,
       "when": "db error (code:\"db\")"
      }
     ],
     "notes": "Returns {\"success\":true,\"count\":0,\"positions\":[]} (HTTP 200) when the user has no active wallet. Requires X-User-Ref tenancy header.",
     "id": "pnl-open-positions"
    },
    {
     "method": "GET",
     "path": "/v1/pnl/position",
     "name": "Open position detail (PNL)",
     "summary": "Returns the single open position for a given mint on the caller's active wallet, with aggregated trade stats (total buys/sells in lamports, buy/sell counts) plus position metadata. Tenant-scoped via X-User-Ref → active wallet_id.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address (required, non-empty).",
       "required": true
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"position\":{\"id\":42,\"wallet_id\":7,\"token_mint\":\"So111...\",\"token_name\":\"Wrapped SOL\",\"token_symbol\":\"SOL\",\"dex_type\":\"pumpfun\",\"status\":\"open\",\"source\":\"swap\",\"opened_at\":1719300000,\"first_buy_signature\":\"5xq...\",\"total_buys_lamports\":1000000,\"total_sells_lamports\":0,\"buy_count\":1,\"sell_count\":0}}",
     "responseNotes": "position is null (with success:true) when no open position exists for the mint or the user has no wallet. total_buys_lamports/total_sells_lamports and buy_count/sell_count are computed from get_trades_by_token; remaining fields come from the positions row.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 422,
       "when": "missing or empty ?mint= (code:\"missing_param\")"
      },
      {
       "code": 500,
       "when": "db error (code:\"db\")"
      }
     ],
     "notes": "No active wallet returns {\"success\":true,\"position\":null} (HTTP 200). Requires X-User-Ref tenancy header.",
     "id": "pnl-position-detail"
    },
    {
     "method": "GET",
     "path": "/v1/pnl/history",
     "name": "Closed position history (realized PNL)",
     "summary": "Returns closed positions for the caller's active wallet with realized PNL (SOL lamports + USD, percent returns, costs, proceeds, fees, tips), most recent first. Tenant-scoped via X-User-Ref → active wallet_id.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max results, default 50, clamped to 1..200.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"positions\":[{\"position_id\":42,\"token_mint\":\"So111...\",\"token_name\":\"Wrapped SOL\",\"token_symbol\":\"SOL\",\"dex_type\":\"pumpfun\",\"source\":\"swap\",\"realized_pnl_sol_lamports\":250000,\"realized_pnl_usd\":0.04,\"pnl_sol_percent\":25.0,\"pnl_usd_percent\":24.0,\"total_cost_lamports\":1000000,\"total_proceeds_lamports\":1250000,\"total_platform_fee_lamports\":10000,\"total_tip_lamports\":50000,\"opened_at\":1719300000,\"closed_at\":1719400000,\"close_sol_price_usd\":160.0,\"buy_count\":1,\"sell_count\":1}]}",
     "responseNotes": "positions[] are ClosedPositionSummary rows. realized_pnl_sol_lamports is realized SOL PnL in lamports; realized_pnl_usd is USD PnL; pnl_sol_percent/pnl_usd_percent are percent returns; total_cost/proceeds/platform_fee/tip are lamports; close_sol_price_usd is the SOL/USD price at close. count = length of positions[]. Ordered by closed_at descending (DB helper).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 500,
       "when": "db error (code:\"db\")"
      }
     ],
     "notes": "No active wallet returns {\"success\":true,\"count\":0,\"positions\":[]} (HTTP 200). limit is clamped to max 200 / min 1. Requires X-User-Ref tenancy header.",
     "id": "pnl-closed-history"
    },
    {
     "method": "GET",
     "path": "/v1/positions/list",
     "name": "List open positions",
     "summary": "Returns all open positions for the authenticated user's active wallet, aggregated from the positions+trades join (total buys/sells in lamports, buy/sell counts, average entry price in USD and SOL, total tips). Tenant-scoped: resolves user_id from the request context and the wallet via the active wallet; if no wallet is found it returns an empty list rather than an error.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"positions\":[{\"token_mint\":\"So111...\",\"token_name\":\"Token\",\"token_symbol\":\"TKN\",\"dex_type\":\"pumpfun\",\"total_buys_lamports\":1000000,\"total_sells_lamports\":0,\"buy_count\":1,\"sell_count\":0,\"avg_entry_price_usd\":0.0001,\"avg_entry_price_sol\":0.0000001,\"first_trade_at\":1700000000,\"last_trade_at\":1700000000,\"source\":\"swap\",\"total_tips_lamports\":50000}]}",
     "responseNotes": "success=true, count=number of open positions, positions[] each with token_mint, token_name, token_symbol, dex_type, total_buys_lamports / total_sells_lamports (summed input/output amounts), buy_count/sell_count, avg_entry_price_usd / avg_entry_price_sol (buy-volume-weighted), first_trade_at (position opened_at, unix secs), last_trade_at, source (swap/copy_trade/sniper/dca/limit), total_tips_lamports. Only trades with status='success' contribute to the aggregates.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "key lacks read scope, or user context cannot be resolved from ctx"
      },
      {
       "code": 500,
       "when": "database error (code=db)"
      }
     ],
     "id": "positions-list"
    },
    {
     "method": "GET",
     "path": "/v1/positions/{id}/trades",
     "name": "List trades for a position",
     "summary": "Returns every trade belonging to a specific position id, ordered oldest-first. Ownership is enforced by joining trades to positions and filtering on the caller's resolved wallet_id, so a position id belonging to another tenant returns an empty trades list. If the caller has no active wallet it returns 404.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Position id (i32) to fetch trades for. Scoped to the caller's wallet via JOIN."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"position_id\":42,\"count\":2,\"trades\":[{\"id\":101,\"wallet_id\":7,\"trade_type\":\"buy\",\"token_mint\":\"So111...\",\"token_name\":\"Token\",\"token_symbol\":\"TKN\",\"dex_type\":\"pumpfun\",\"input_amount\":1000000,\"output_amount\":50000000,\"price_usd\":0.0001,\"price_sol\":0.0000001,\"signature\":\"5x...\",\"status\":\"success\",\"buy_mode\":\"swap\",\"position_id\":42,\"created_at\":1700000000,\"tip_lamports\":50000,\"platform_fee_lamports\":10000,\"sol_price_usd\":150.0}]}",
     "responseNotes": "success=true, position_id echoes the path param, count=number of trades, trades[] each with id, wallet_id, trade_type (buy/sell), token_mint/name/symbol, dex_type, input_amount, output_amount, price_usd, price_sol, signature, status, buy_mode, position_id, created_at (unix secs), tip_lamports, platform_fee_lamports, sol_price_usd. Ordered created_at ASC.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "key lacks read scope, or user context cannot be resolved from ctx"
      },
      {
       "code": 404,
       "when": "caller has no active wallet (position not found, code=not_found)"
      },
      {
       "code": 500,
       "when": "database error (code=db)"
      }
     ],
     "id": "positions-position-trades"
    },
    {
     "method": "POST",
     "path": "/v1/positions/pnl-card",
     "name": "(Not yet available) Generate PNL card",
     "summary": "Intended to render a PNL card PNG, but requires a separate image-rendering service not available in DB-only mode, so it always returns 501.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "body",
       "type": "object",
       "description": "Arbitrary JSON body (accepted but ignored; the handler returns 501 before reading it).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"pnl-card requires a PNG rendering service not available in DB-only mode (P3+)\"}}",
     "responseNotes": "Always 501 NOT_IMPLEMENTED. Body is accepted via ApiJson but never processed.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 501,
       "when": "always — PNG rendering service unavailable in DB-only mode"
      }
     ],
     "notes": "501 stub (not yet implemented) — requires a separate PNG rendering service (P3+).",
     "id": "positions-pnl-card"
    },
    {
     "method": "GET",
     "path": "/v1/positions/trades",
     "name": "List trades by token mint",
     "summary": "Returns all trades for a given token mint across the caller's active wallet (via db.get_trades_by_token). The ?mint= query parameter is required. If the caller has no active wallet it returns an empty trades list rather than an error.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address to filter trades by. Required and must be non-empty.",
       "required": true
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"mint\":\"So111...\",\"count\":1,\"trades\":[{\"id\":101,\"wallet_id\":7,\"trade_type\":\"sell\",\"token_mint\":\"So111...\",\"token_name\":\"Token\",\"token_symbol\":\"TKN\",\"dex_type\":\"pumpfun\",\"input_amount\":50000000,\"output_amount\":1100000,\"price_usd\":0.00011,\"price_sol\":0.00000011,\"signature\":\"5x...\",\"status\":\"success\",\"buy_mode\":\"swap\",\"position_id\":42,\"created_at\":1700000000,\"tip_lamports\":50000,\"platform_fee_lamports\":10000,\"sol_price_usd\":150.0}]}",
     "responseNotes": "success=true, mint echoes the query param, count=number of trades, trades[] same per-trade shape as positions/{id}/trades (id, wallet_id, trade_type, token_mint/name/symbol, dex_type, input_amount, output_amount, price_usd, price_sol, signature, status, buy_mode, position_id, created_at, tip_lamports, platform_fee_lamports, sol_price_usd).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "key lacks read scope, or user context cannot be resolved from ctx"
      },
      {
       "code": 422,
       "when": "?mint= query parameter missing or empty (code=missing_param)"
      },
      {
       "code": 500,
       "when": "database error (code=db)"
      }
     ],
     "id": "positions-trades-by-mint"
    }
   ]
  },
  {
   "label": "Trading API: Indexer",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Read-only on-chain token + pool index — metadata, pool params, recent tokens/pools, search, and per-DEX stats.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/indexer/token",
     "name": "Get indexed token metadata",
     "summary": "Looks up a single token in the in-process indexer by mint and returns its on-chain metadata (name/symbol/decimals/token_program/uri/supply/creator/source). Read-only the database query against the indexer's 460K+ token cache.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address to look up",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"mint\":\"So111...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"decimals\":6,\"token_program\":\"TokenkegQ...\",\"uri\":\"https://...\",\"total_supply\":1000000000,\"creator\":\"5xyz...\",\"source\":\"pumpfun\",\"indexed_at\":1719331200}}",
     "responseNotes": "data is the full token row, or null when the mint isn't indexed. success:true even on a miss (data:null).",
     "errorCodes": [
      {
       "code": 400,
       "when": "missing mint param (returns success:false code=indexer_error in 200 body)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Errors are returned as success:false envelopes in a 200 body (not HTTP status codes): missing mint -> code=indexer_error; no indexer -> code=indexer_unavailable.",
     "id": "indexer-token"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/pool",
     "name": "Get indexed pool by mint",
     "summary": "Returns the latest indexed pool for a mint, or a specific DEX's pool when dex_type is supplied (synonyms normalized). Carries pool_address, vaults, token programs, raw dex_data, creator and launch slot/signature/block_time for fast pool-param lookups.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address",
       "required": true
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX filter (canonical or synonym, e.g. pumpfun/pump, raydium_cpmm/raycpmm, orca/whirlpool). When omitted, returns the most recently indexed pool for the mint.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"id\":123,\"pool_address\":\"7pool...\",\"dex_type\":\"pumpswap\",\"base_mint\":\"5mint...\",\"quote_mint\":\"So111...\",\"base_vault\":\"Bvlt...\",\"quote_vault\":\"Qvlt...\",\"base_token_program\":\"Tokenkeg...\",\"quote_token_program\":\"Tokenkeg...\",\"dex_data\":\"{...}\",\"creator\":\"5xyz...\",\"slot\":271234567,\"signature\":\"3sig...\",\"block_time\":1719331200}}",
     "responseNotes": "data is a full pool row or null when no pool is indexed for the mint. dex_data is a JSON string of DEX-specific layout fields.",
     "errorCodes": [
      {
       "code": 400,
       "when": "missing mint param (success:false code=indexer_error in 200 body)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Error conditions are returned as success:false envelopes inside a 200 body, not via HTTP status codes.",
     "id": "indexer-pool"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/pool-by-address",
     "name": "Get indexed pool by address",
     "summary": "Looks up a single indexed pool directly by its on-chain pool/pair address and returns the same pool row shape as /indexer/pool.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "address",
       "type": "string",
       "description": "Pool/pair on-chain address",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"id\":123,\"pool_address\":\"7pool...\",\"dex_type\":\"raydium_cpmm\",\"base_mint\":\"5mint...\",\"quote_mint\":\"So111...\",\"base_vault\":\"Bvlt...\",\"quote_vault\":\"Qvlt...\",\"base_token_program\":\"Tokenkeg...\",\"quote_token_program\":\"Tokenkeg...\",\"dex_data\":\"{...}\",\"creator\":\"5xyz...\",\"slot\":271234567,\"signature\":\"3sig...\",\"block_time\":1719331200}}",
     "responseNotes": "data is the pool row or null when the address isn't indexed.",
     "errorCodes": [
      {
       "code": 400,
       "when": "missing address param (success:false code=indexer_error in 200 body)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Error conditions are returned as success:false envelopes inside a 200 body.",
     "id": "indexer-pool-by-address"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/recent-tokens",
     "name": "List recent indexed tokens",
     "summary": "Returns the most recently indexed tokens, optionally filtered to one DEX. Sorted by indexed_at DESC. Returns lightweight token metadata rows (no price/metrics).",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows, default 20, clamped to 1..200",
       "required": false
      },
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX filter (canonical or synonym). When omitted, returns recent tokens across all DEXes.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":[{\"mint\":\"5mint...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"decimals\":6,\"token_program\":\"Tokenkeg...\",\"uri\":\"https://...\",\"total_supply\":1000000000,\"creator\":\"5xyz...\",\"source\":\"pumpfun\",\"indexed_at\":1719331200}]}",
     "responseNotes": "data is an array of token rows (TokenResponse). Empty array when none match.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "DB errors return success:false code=indexer_error inside a 200 body. limit is clamped server-side to 1..200.",
     "id": "indexer-recent-tokens"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/recent-by-dex",
     "name": "Recent tokens grouped by DEX (metrics-enriched)",
     "summary": "Returns the most recent tokens for every supported DEX in one call, grouped by DEX name, each enriched with token_metrics (price/mcap/liquidity/rolling-window volume + swap counts) and resolved off-chain metadata (image/socials/description). USD values computed server-side from the cached SOL/USD price.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows per DEX, default 5, clamped to 1..50",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"sol_usd\":152.3,\"pumpfun\":[{\"mint\":\"5mint...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"decimals\":6,\"token_program\":\"Tokenkeg...\",\"uri\":\"https://...\",\"total_supply\":1000000000,\"creator\":\"5xyz...\",\"pool_address\":\"7pool...\",\"quote_mint\":\"So111...\",\"dex_type\":\"pumpfun\",\"indexed_at\":1719331200,\"image_url\":\"https://...\",\"twitter_url\":null,\"website_url\":null,\"telegram_url\":null,\"description\":null,\"price_sol\":0.0000001,\"price_usd\":0.0000152,\"liquidity_sol\":30.0,\"liquidity_usd\":4569.0,\"mcap_sol\":100.0,\"mcap_usd\":15230.0,\"volume_5m_usd\":0.0,\"volume_1h_usd\":0.0,\"volume_6h_usd\":0.0,\"volume_24h_usd\":0.0,\"swaps_5m\":0,\"swaps_1h\":0,\"swaps_6h\":0,\"swaps_24h\":0,\"last_swap_at\":null}]}}",
     "responseNotes": "data is a map: sol_usd (the SOL/USD price used for conversions) plus one key per DEX (only DEXes with rows are included) whose value is an array of metrics-enriched token rows. price_sol falls back to creation reserves when no live swap price exists; mcap derived from price x circulating supply when the aggregator value is absent.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Per-DEX queries run concurrently (one tokio task per DEX in dex_registry::ALL_DB_NAMES). DEXes with no rows are omitted from the map.",
     "id": "indexer-recent-by-dex"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/recent-pools",
     "name": "List recent pool launches",
     "summary": "Pool launches sorted by indexer insertion time (DESC), LEFT JOIN'd with indexed_tokens so name/symbol/uri are included once enrichment lands. Each row has a launch tx signature to deep-link to Solscan. Optional DEX filter validated against the canonical allow-list.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "dex_type",
       "type": "string",
       "description": "Optional DEX filter (canonical or synonym; validated against the allow-list). When omitted, returns launches across all DEXes.",
       "required": false
      },
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows (default 50; not clamped here, passed to the query)",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"data\":[{\"mint\":\"5mint...\",\"pool_address\":\"7pool...\",\"dex_type\":\"pumpfun\",\"signature\":\"3sig...\",\"block_time\":1719331200,\"indexed_at\":1719331205,\"creator\":\"5xyz...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"uri\":\"https://...\"}]}",
     "responseNotes": "data is an array of RecentPoolResponse. name/symbol/uri are nullable (LEFT JOIN — null until token enrichment catches up). block_time may be null on the first batch after a fresh restart; indexed_at is always populated.",
     "errorCodes": [
      {
       "code": 400,
       "when": "unknown dex_type (success:false code=indexer_error 'Unknown dex_type: X' in 200 body)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Unknown dex_type is rejected up front (validated against dex_registry::ALL_DB_NAMES). Error conditions returned as success:false envelopes in a 200 body.",
     "id": "indexer-recent-pools"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/search",
     "name": "Search tokens",
     "summary": "Case-insensitive LIKE search over indexed tokens by name/symbol/mint, returning DB-metric-enriched results (price_sol, volume_24h_usd, liquidity_usd, mcap_usd) sorted by the chosen key. USD values computed from the cached SOL/USD price.",
     "authRequired": true,
     "scope": "read",
     "queryParams": [
      {
       "name": "q",
       "type": "string",
       "description": "Search query (alias: query). Required and non-empty.",
       "required": true
      },
      {
       "name": "sort",
       "type": "string",
       "description": "Sort key, default 'mc' (market cap)",
       "required": false
      },
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows, default 10, capped at 50",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"count\":1,\"data\":[{\"mint\":\"5mint...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"decimals\":6,\"dex_type\":\"pumpfun\",\"pool_address\":\"7pool...\",\"block_time\":1719331200,\"price_sol\":0.0000001,\"volume_24h_usd\":1234.5,\"liquidity_usd\":4569.0,\"mcap_usd\":15230.0}]}",
     "responseNotes": "Includes a top-level count plus data array. Metrics are DB-only (no live SwapAggregator enrichment in this state); callers needing rolling-window stats should use /indexer/stats or the test-api. LIKE special chars in q are escaped server-side.",
     "errorCodes": [
      {
       "code": 400,
       "when": "missing/empty q param (success:false code=indexer_error in 200 body)"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "q accepts the alias 'query'. Error conditions returned as success:false envelopes in a 200 body.",
     "id": "indexer-search"
    },
    {
     "method": "GET",
     "path": "/v1/indexer/stats",
     "name": "Indexer statistics",
     "summary": "Returns aggregate indexer counters: total/enriched/pending/failed pools, total tokens, and a per-DEX pool-count breakdown.",
     "authRequired": true,
     "scope": "read",
     "responseExample": "{\"success\":true,\"data\":{\"total_pools\":460123,\"enriched_pools\":455000,\"pending_pools\":5000,\"failed_pools\":123,\"total_tokens\":461000,\"pools_per_dex\":{\"pumpfun\":300000,\"pumpswap\":80000,\"raydium_cpmm\":40000}}}",
     "responseNotes": "data carries the four pool-state counters, total_tokens, and pools_per_dex (a map of canonical DEX name to pool count).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "DB errors return success:false code=indexer_error in a 200 body. pools_per_dex defaults to empty on its sub-query failing.",
     "id": "indexer-stats"
    },
    {
     "method": "POST",
     "path": "/v1/indexer/tokens/batch",
     "name": "Batch token lookup",
     "summary": "Enriches up to 100 mints in one round-trip, returning a map of mint to its full token row. Mints not found are omitted from the map (not null-valued) so callers can detect coverage gaps cheaply.",
     "authRequired": true,
     "scope": "read",
     "bodyParams": [
      {
       "name": "mints",
       "type": "string[]",
       "description": "Token mint addresses to look up (max 100). Empty array returns an empty map.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"data\":{\"5mint...\":{\"mint\":\"5mint...\",\"name\":\"Token\",\"symbol\":\"TKN\",\"decimals\":6,\"token_program\":\"Tokenkeg...\",\"uri\":\"https://...\",\"total_supply\":1000000000,\"creator\":\"5xyz...\",\"source\":\"pumpfun\",\"indexed_at\":1719331200}}}",
     "responseNotes": "data is a map keyed by mint to the full token row (TokenResponse). Mints not indexed are absent from the map. Empty mints array yields data:{}.",
     "errorCodes": [
      {
       "code": 400,
       "when": ">100 mints (success:false code=indexer_error 'Too many mints' in 200 body); malformed JSON body returns ApiJson error envelope"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 503,
       "when": "indexer not configured (code=indexer_unavailable in body)"
      }
     ],
     "notes": "Body parsed via ApiJson (returns a structured error envelope on malformed JSON). The >100-mints and no-indexer cases return success:false envelopes in a 200 body.",
     "id": "indexer-tokens-batch"
    }
   ]
  },
  {
   "label": "Trading API: Limit Orders & DCA",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Automated limit orders and dollar-cost-averaging schedules. automation scope; per-tenant; supports an idempotency key.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/limit/create",
     "name": "Create limit order",
     "summary": "Creates a limit/conditional order row in the limit_orders table for the calling tenant's active wallet (price/schedule/event trigger, buy or sell). DB-only write; no on-chain action — execution is handled later by the bot's PriceMonitor service. Scoped to the user resolved from the API key + X-User-Ref.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address.",
       "required": true
      },
      {
       "name": "amount_sol",
       "type": "number",
       "description": "Order size in SOL (converted to lamports). Required — 422 if omitted.",
       "required": true
      },
      {
       "name": "trigger_type",
       "type": "string",
       "description": "One of price | schedule | event. Default 'price'.",
       "required": false
      },
      {
       "name": "trigger_price_usd",
       "type": "number",
       "description": "Target USD price for price triggers (stored as 0.0 if omitted).",
       "required": false
      },
      {
       "name": "trigger_time_seconds",
       "type": "int",
       "description": "For trigger_type=schedule: seconds from now until trigger_at.",
       "required": false
      },
      {
       "name": "event_type",
       "type": "string",
       "description": "For trigger_type=event: event name (e.g. 'migration'). Stored as trigger_direction 'event:<event_type>'. Defaults to 'migration'.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in bps. Default 1000.",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via MEV-protected providers. Default true.",
       "required": false
      },
      {
       "name": "expiry_seconds",
       "type": "int",
       "description": "Seconds until the order expires. Default 86400 (24h).",
       "required": false
      },
      {
       "name": "side",
       "type": "string",
       "description": "buy | sell. Default 'buy'. Determines trigger_direction (sell=below, buy=above) for price triggers.",
       "required": false
      },
      {
       "name": "sell_percent",
       "type": "int",
       "description": "For sell orders: percent of holdings to sell.",
       "required": false
      },
      {
       "name": "trailing_pct",
       "type": "number",
       "description": "Trailing stop percentage (optional).",
       "required": false
      },
      {
       "name": "max_retries",
       "type": "int",
       "description": "Max execution retries. Default 0.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"order\":{\"id\":42,\"token_mint\":\"So111...\",\"token_name\":null,\"token_symbol\":null,\"dex_type\":\"Auto\",\"pair_address\":null,\"trigger_type\":\"price\",\"trigger_price_usd\":0.05,\"trigger_direction\":\"above\",\"trigger_at\":null,\"event_type\":null,\"price_at_creation\":null,\"side\":\"buy\",\"amount_lamports\":1000000,\"amount_sol\":0.001,\"sell_percent\":null,\"slippage_bps\":1000,\"mev_protect\":true,\"status\":\"pending\",\"created_at\":\"2026-06-25T12:00:00+00:00\",\"expires_at\":\"2026-06-26T12:00:00+00:00\",\"triggered_at\":null,\"executed_at\":null,\"execution_signature\":null,\"error_message\":null}}",
     "responseNotes": "success=true with the full created order object (from order_to_json). order.id is the new order id; status starts 'pending'; amount_sol is derived from amount_lamports. If the row can't be re-read, order collapses to just {\"id\":<n>}.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 422,
       "when": "amount_sol omitted (code 'missing_field'), no active wallet for user (code 'no_wallet'), or malformed JSON body"
      },
      {
       "code": 500,
       "when": "DB error creating/reading the order (code 'db')"
      }
     ],
     "notes": "Honors Idempotency-Key header (automation routes pass through the idempotency layer).",
     "id": "limit-create"
    },
    {
     "method": "GET",
     "path": "/v1/limit/list",
     "name": "List pending limit orders",
     "summary": "Returns all pending limit orders for the calling tenant's user. Read from the limit_orders table, scoped to the resolved user id.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"orders\":[{\"id\":42,\"token_mint\":\"So111...\",\"trigger_type\":\"price\",\"trigger_price_usd\":0.05,\"trigger_direction\":\"above\",\"side\":\"buy\",\"amount_lamports\":1000000,\"amount_sol\":0.001,\"slippage_bps\":1000,\"mev_protect\":true,\"status\":\"pending\",\"created_at\":\"2026-06-25T12:00:00+00:00\",\"expires_at\":\"2026-06-26T12:00:00+00:00\"}]}",
     "responseNotes": "count = number of pending orders; orders is an array of the same order object shape as /limit/create. Only status='pending' orders are returned.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 200,
       "when": "DB error returns success=false with an 'error' string (HTTP still 200)"
      }
     ],
     "id": "limit-list"
    },
    {
     "method": "GET",
     "path": "/v1/limit/monitor",
     "name": "(Not yet available) Limit monitor status",
     "summary": "Intended to report PriceMonitor execution status. Not yet wired in the multi-tenant API — returns 501.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"limit order execution requires trading executor\"}}",
     "responseNotes": "Always 501 — the price-monitor/trading-executor service is not available in this API process.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 501,
       "when": "always — execution requires the trading executor"
      }
     ],
     "notes": "501 stub (not yet implemented).",
     "id": "limit-monitor"
    },
    {
     "method": "DELETE",
     "path": "/v1/limit/all",
     "name": "Cancel all limit orders",
     "summary": "Cancels every pending limit order for the calling tenant's user. Scoped to the resolved user id.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"cancelled\":3}",
     "responseNotes": "cancelled = number of orders that were cancelled.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 200,
       "when": "DB error returns success=false with an 'error' string (HTTP still 200)"
      }
     ],
     "notes": "Honors Idempotency-Key header (automation routes pass through the idempotency layer).",
     "id": "limit-cancel-all"
    },
    {
     "method": "GET",
     "path": "/v1/limit/{id}",
     "name": "Get limit order",
     "summary": "Fetches a single limit order by id, scoped to the calling tenant's user (cross-user access blocked by user_id filter).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Limit order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"order\":{\"id\":42,\"token_mint\":\"So111...\",\"trigger_type\":\"price\",\"trigger_price_usd\":0.05,\"side\":\"buy\",\"amount_lamports\":1000000,\"amount_sol\":0.001,\"status\":\"pending\",\"created_at\":\"2026-06-25T12:00:00+00:00\"}}",
     "responseNotes": "success=true with the full order object (order_to_json). If not found/owned, success=false with error 'Order not found' (HTTP 200).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 200,
       "when": "order not found/not owned, or DB error: success=false with 'error' string (HTTP still 200)"
      }
     ],
     "id": "limit-get-order"
    },
    {
     "method": "DELETE",
     "path": "/v1/limit/{id}",
     "name": "Cancel limit order",
     "summary": "Cancels a single pending limit order by id, scoped to the calling tenant's user. Idempotent per order (cancelling an already-cancelled/unknown order returns success=false).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Limit order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Order #42 cancelled\"}",
     "responseNotes": "success=true with a confirmation message when cancelled. If the order isn't found/owned or was already cancelled, success=false with error 'Order not found or already cancelled' (HTTP 200).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "API key lacks 'automation' scope"
      },
      {
       "code": 200,
       "when": "order not found/already cancelled, or DB error: success=false with 'error' string (HTTP still 200)"
      }
     ],
     "notes": "Honors Idempotency-Key header (automation routes pass through the idempotency layer).",
     "id": "limit-cancel"
    },
    {
     "method": "POST",
     "path": "/v1/dca/create",
     "name": "(Not yet available) Create DCA order",
     "summary": "Intended to create a recurring dollar-cost-average buy/sell order for the authenticated tenant. Currently a 501 stub: it requires rpc/indexer resources that are not yet wired into V1State (P1 Group C), so it always returns NOT_IMPLEMENTED.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint address to DCA into/out of.",
       "required": true
      },
      {
       "name": "amount_sol",
       "type": "number",
       "description": "SOL amount per interval (buy side).",
       "required": false
      },
      {
       "name": "interval_seconds",
       "type": "int",
       "description": "Seconds between each DCA execution.",
       "required": true
      },
      {
       "name": "total_orders",
       "type": "int",
       "description": "Total number of orders to execute before the schedule completes.",
       "required": false
      },
      {
       "name": "min_price",
       "type": "number",
       "description": "Lower price guard (USD); skip execution below this.",
       "required": false
      },
      {
       "name": "max_price",
       "type": "number",
       "description": "Upper price guard (USD); skip execution above this.",
       "required": false
      },
      {
       "name": "slippage_bps",
       "type": "int",
       "description": "Slippage tolerance in basis points. Default 1000.",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route via MEV-protected providers. Default true.",
       "required": false
      },
      {
       "name": "duration_seconds",
       "type": "int",
       "description": "Overall lifetime of the schedule in seconds before it expires.",
       "required": false
      },
      {
       "name": "side",
       "type": "string",
       "description": "DCA direction: 'buy' or 'sell'. Default 'buy'.",
       "required": false
      },
      {
       "name": "sell_percent",
       "type": "int",
       "description": "Percent of holdings to sell each interval (sell side).",
       "required": false
      }
     ],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"DCA creation requires rpc/indexer which are not yet wired to V1State (P1 Group C)\"}}",
     "responseNotes": "Always returns 501 NOT_IMPLEMENTED. Once wired it will create a dca_orders row scoped to the authenticated tenant.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "invalid/missing body fields (e.g. missing mint or interval_seconds)"
      },
      {
       "code": 501,
       "when": "always — handler is a stub pending rpc/indexer wiring"
      }
     ],
     "notes": "501 stub (not yet implemented). Mutation route — supports Idempotency-Key header via the automation idempotency layer.",
     "id": "dca-create"
    },
    {
     "method": "GET",
     "path": "/v1/dca/list",
     "name": "List DCA orders",
     "summary": "Returns all active and paused DCA orders for the authenticated tenant (resolved from X-User-Ref via the tenancy layer). Active orders are listed first, then paused.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"orders\":[{\"id\":42,\"token_mint\":\"So111...\",\"token_name\":\"Foo\",\"token_symbol\":\"FOO\",\"dex_type\":\"pumpfun\",\"pair_address\":\"...\",\"amount_lamports\":1000000,\"amount_sol\":0.001,\"interval_seconds\":3600,\"total_orders\":10,\"orders_executed\":2,\"min_price_usd\":null,\"max_price_usd\":null,\"slippage_bps\":1000,\"mev_protect\":true,\"notifications_enabled\":true,\"status\":\"active\",\"next_execution_at\":\"2026-06-26T12:00:00+00:00\",\"created_at\":\"2026-06-26T10:00:00+00:00\",\"last_executed_at\":\"2026-06-26T11:00:00+00:00\",\"duration_seconds\":null,\"expires_at\":null,\"total_sol_spent\":2000000,\"total_sol_spent_display\":0.002,\"total_tokens_bought\":12345.0}]}",
     "responseNotes": "count = number of orders returned (active + paused combined). Each order: amount_lamports/amount_sol = per-interval size; orders_executed/total_orders = progress; total_sol_spent (lamports) and total_sol_spent_display (SOL) = cumulative spend; total_tokens_bought = cumulative tokens acquired; status is 'active' or 'paused'; timestamps are RFC3339 (nullable).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user (require_user fails)"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "id": "dca-list"
    },
    {
     "method": "GET",
     "path": "/v1/dca/{id}",
     "name": "Get DCA order",
     "summary": "Returns a single DCA order by id, scoped to the authenticated tenant (dca_get_by_id filters by user). Returns success:false with 'Order not found' if it does not exist or belongs to another tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "DCA order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"order\":{\"id\":42,\"token_mint\":\"So111...\",\"token_symbol\":\"FOO\",\"dex_type\":\"pumpfun\",\"amount_lamports\":1000000,\"amount_sol\":0.001,\"interval_seconds\":3600,\"total_orders\":10,\"orders_executed\":2,\"slippage_bps\":1000,\"mev_protect\":true,\"status\":\"active\",\"next_execution_at\":\"2026-06-26T12:00:00+00:00\",\"created_at\":\"2026-06-26T10:00:00+00:00\",\"total_sol_spent\":2000000,\"total_sol_spent_display\":0.002,\"total_tokens_bought\":12345.0}}",
     "responseNotes": "order is the full order_to_json shape (same fields as /v1/dca/list elements). On miss returns {\"success\":false,\"error\":\"Order not found\"} with HTTP 200; on DB failure {\"success\":false,\"error\":\"DB error:...\"}.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "notes": "Not-found and DB errors are returned as success:false in a 200 body, not as HTTP error codes.",
     "id": "dca-get-order"
    },
    {
     "method": "POST",
     "path": "/v1/dca/{id}/pause",
     "name": "Pause DCA order",
     "summary": "Pauses an active DCA order owned by the authenticated tenant (dca_pause filters by user). No further executions occur until resumed.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "DCA order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"DCA #42 paused\"}",
     "responseNotes": "success:true with a message when the order transitions active→paused. {\"success\":false,\"error\":\"Order not found or not active\"} if no matching active order; {\"success\":false,\"error\":\"Failed to pause:...\"} on DB error.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "notes": "Mutation route — supports Idempotency-Key header via the automation idempotency layer. Not-found/DB errors returned as success:false in 200 body.",
     "id": "dca-pause"
    },
    {
     "method": "POST",
     "path": "/v1/dca/{id}/resume",
     "name": "Resume DCA order",
     "summary": "Resumes a paused DCA order owned by the authenticated tenant (dca_resume filters by user). Re-enables scheduled executions.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "DCA order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"DCA #42 resumed\"}",
     "responseNotes": "success:true with a message when the order transitions paused→active. {\"success\":false,\"error\":\"Order not found or not paused\"} if no matching paused order; {\"success\":false,\"error\":\"Failed to resume:...\"} on DB error.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "notes": "Mutation route — supports Idempotency-Key header via the automation idempotency layer. Not-found/DB errors returned as success:false in 200 body.",
     "id": "dca-resume"
    },
    {
     "method": "DELETE",
     "path": "/v1/dca/{id}",
     "name": "Cancel DCA order",
     "summary": "Cancels a single DCA order owned by the authenticated tenant (dca_cancel filters by user). Idempotent at the data level — already-cancelled orders return not-found.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "DCA order id (i64)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"DCA #42 cancelled\"}",
     "responseNotes": "success:true with a message when an active/paused order is cancelled. {\"success\":false,\"error\":\"Order not found or already cancelled\"} if no matching order; {\"success\":false,\"error\":\"Failed to cancel:...\"} on DB error.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "notes": "Mutation route — supports Idempotency-Key header via the automation idempotency layer. Not-found/DB errors returned as success:false in 200 body.",
     "id": "dca-cancel"
    },
    {
     "method": "DELETE",
     "path": "/v1/dca/all",
     "name": "Cancel all DCA orders",
     "summary": "Cancels every DCA order for the authenticated tenant (dca_cancel_all scoped by user) and returns how many were cancelled.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"cancelled\":3}",
     "responseNotes": "cancelled = number of orders cancelled for this tenant (0 if none). {\"success\":false,\"error\":\"Failed to cancel:...\"} on DB error.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key or unresolved user"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      }
     ],
     "notes": "Mutation route — supports Idempotency-Key header via the automation idempotency layer. Registered before /dca/{id} so 'all' is not captured as an id path param.",
     "id": "dca-cancel-all"
    }
   ]
  },
  {
   "label": "Trading API: Copy Trade",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Copy-trading configs, sources, filters, blacklists, and execution stats. automation scope; per-tenant.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/copy-trade/start",
     "name": "(Not yet available) Start copy-trade service",
     "summary": "Intended to start the live copy-trading engine for the caller. Currently a 501 stub — the copy-trade runtime service is not wired into V1State in this deployment.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref. Idempotency-Key header accepted (automation idempotency layer).",
     "id": "copytrade-start"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/stop",
     "name": "(Not yet available) Stop copy-trade service",
     "summary": "Intended to stop the live copy-trading engine for the caller. Currently a 501 stub — the copy-trade runtime service is not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref. Idempotency-Key header accepted.",
     "id": "copytrade-stop"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/status",
     "name": "(Not yet available) Copy-trade service status (caller)",
     "summary": "Intended to report the caller's copy-trade engine status. Currently a 501 stub — requires the live copy-trade service not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref.",
     "id": "copytrade-status"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/system-status",
     "name": "(Not yet available) Copy-trade system status",
     "summary": "Intended to report global copy-trade engine/system health. Currently a 501 stub — requires the live copy-trade service not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref. No utoipa annotation (undocumented in OpenAPI).",
     "id": "copytrade-system-status"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/add-source",
     "name": "(Not yet available) Add copy-trade source wallet",
     "summary": "Intended to register a source wallet to follow. Currently a 501 stub — requires the live copy-trade service not wired into V1State; the body is accepted but ignored.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "source_id",
       "type": "int",
       "description": "Source id to attach.",
       "required": true
      },
      {
       "name": "wallet_address",
       "type": "string",
       "description": "Solana wallet address of the source to follow.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope; body is parsed (ApiJson) but never used.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Idempotency-Key header accepted. No utoipa annotation.",
     "id": "copytrade-add-source"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/remove-source",
     "name": "(Not yet available) Remove copy-trade source wallet",
     "summary": "Intended to unregister a source wallet. Currently a 501 stub — requires the live copy-trade service not wired into V1State; the body is accepted but ignored.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "source_id",
       "type": "int",
       "description": "Source id to remove.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope; body is parsed (ApiJson) but never used.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Idempotency-Key header accepted. No utoipa annotation.",
     "id": "copytrade-remove-source"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/create-config",
     "name": "Create copy-trade config",
     "summary": "Creates a new copy-trade config for the caller's active wallet following the given source wallet, then activates it. Scoped to the X-User-Ref tenant; uses BuyMode::Fixed with buy_amount_sol.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "source_wallet",
       "type": "string",
       "description": "Solana pubkey of the wallet to copy.",
       "required": true
      },
      {
       "name": "buy_amount_sol",
       "type": "number",
       "description": "Fixed SOL amount per copied buy. Must be between 0.001 and 100.",
       "required": true
      },
      {
       "name": "buy_slippage_bps",
       "type": "int",
       "description": "Buy slippage in bps. Defaults to 1000 (10%).",
       "required": false
      },
      {
       "name": "sell_slippage_bps",
       "type": "int",
       "description": "Sell slippage in bps. Defaults to 1000 (10%).",
       "required": false
      },
      {
       "name": "name",
       "type": "string",
       "description": "Config label. Defaults to 'API Config'.",
       "required": false
      },
      {
       "name": "observe_only",
       "type": "bool",
       "description": "If true, config observes/logs without executing trades. Defaults to false.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":123,\"source_id\":45,\"source_wallet\":\"So111...\",\"buy_amount_sol\":0.5,\"buy_slippage_bps\":1000,\"sell_slippage_bps\":1000,\"message\":\"Config created and activated.\"}",
     "responseNotes": "Returns the new config_id and the resolved source_id. Config is created, activated (ct_toggle_config true), optional observe_only set, and the source row get-or-created.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "business error (invalid source_wallet, buy_amount_sol out of 0.001–100 range, no active wallet, DB failure) returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref (resolves bot user + active wallet). Idempotency-Key header accepted. Validation/business failures return HTTP 200 with {success:false,error:\"...\"}.",
     "id": "copytrade-create-config"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/delete-config",
     "name": "Delete copy-trade config",
     "summary": "Deletes one of the caller's copy-trade configs by id. Ownership-scoped to the X-User-Ref tenant (ct_delete_config filters by user).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "config_id",
       "type": "int",
       "description": "Id of the config to delete.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Config 5 deleted\"}",
     "responseNotes": "Returns a confirmation message on success.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "DB/delete failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. No utoipa annotation.",
     "id": "copytrade-delete-config"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/configs",
     "name": "List copy-trade configs",
     "summary": "Lists all copy-trade configs owned by the caller (X-User-Ref tenant), each fully serialized via config_to_json.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"configs\":[{\"id\":5,\"name\":\"API Config\",\"source_id\":45,\"wallet_id\":12,\"is_active\":true,\"copy_buys\":true,\"copy_sells\":true,\"follow_sell_percent\":true,\"buy_mode\":\"fixed\",\"buy_fixed_amount_sol\":0.5,\"buy_percent\":null,\"max_buy_amount_sol\":null,\"min_trigger_buy_sol\":null,\"max_trigger_buy_sol\":null,\"buy_slippage_bps\":1000,\"sell_slippage_bps\":1000,\"buy_priority_fee\":null,\"sell_priority_fee\":null,\"buy_tip\":null,\"sell_tip\":null,\"auto_tip\":false,\"buy_protection\":false,\"sell_protection\":false,\"max_buy_count\":null,\"current_buy_count\":0,\"max_per_token\":null,\"first_interaction_only\":false,\"sell_only_copied\":false,\"buy_only_once\":false,\"skip_deploys\":false,\"reverse_mode\":false,\"sell_on_transfer\":false,\"reverse_min_sell_percent\":null,\"observe_only\":false,\"start_time\":null,\"end_time\":null,\"notify_success\":true,\"notify_failed\":true,\"notify_skipped\":false,\"notify_filtered\":false,\"autosell_profile_id\":null,\"blacklist\":[],\"filters\":{},\"created_at\":\"...\",\"updated_at\":\"...\"}],\"count\":1}",
     "responseNotes": "configs[] is the full config object (config_to_json); count is the array length. Empty array if the user has no configs.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-list-configs"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}",
     "name": "Get copy-trade config",
     "summary": "Returns one copy-trade config by id, fully serialized. Ownership-scoped to the X-User-Ref tenant (ct_get_config filters by user).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config\":{\"id\":5,\"name\":\"API Config\",\"source_id\":45,\"wallet_id\":12,\"is_active\":true,\"copy_buys\":true,\"copy_sells\":true,\"buy_mode\":\"fixed\",\"buy_fixed_amount_sol\":0.5,\"buy_slippage_bps\":1000,\"sell_slippage_bps\":1000,\"blacklist\":[],\"filters\":{},\"created_at\":\"...\",\"updated_at\":\"...\"}}",
     "responseNotes": "config is the full config_to_json object (same field set as list_configs entries).",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "config not found (other user / unknown id) or DB error returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Not-found returns HTTP 200 with success:false (\"Config {id} not found\").",
     "id": "copytrade-get-config"
    },
    {
     "method": "PUT",
     "path": "/v1/copy-trade/config/{id}",
     "name": "Update copy-trade config",
     "summary": "Partial update of a copy-trade config — every body field is optional and only provided fields are applied. Ownership-scoped; returns the full updated config.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "buy_mode",
       "type": "string",
       "description": "One of: fixed, exact, percent.",
       "required": false
      },
      {
       "name": "buy_amount_sol",
       "type": "number",
       "description": "Fixed buy amount in SOL (converted to lamports).",
       "required": false
      },
      {
       "name": "buy_percent",
       "type": "int",
       "description": "Percent buy size (used with buy_mode=percent).",
       "required": false
      },
      {
       "name": "max_buy_amount_sol",
       "type": "number",
       "description": "Cap per buy in SOL.",
       "required": false
      },
      {
       "name": "min_trigger_buy_sol",
       "type": "number",
       "description": "Min source-buy size (SOL) to trigger a copy.",
       "required": false
      },
      {
       "name": "max_trigger_buy_sol",
       "type": "number",
       "description": "Max source-buy size (SOL) to trigger a copy.",
       "required": false
      },
      {
       "name": "buy_slippage_bps",
       "type": "int",
       "description": "Buy slippage in bps.",
       "required": false
      },
      {
       "name": "sell_slippage_bps",
       "type": "int",
       "description": "Sell slippage in bps.",
       "required": false
      },
      {
       "name": "buy_priority_fee",
       "type": "int",
       "description": "Buy priority fee (lamports).",
       "required": false
      },
      {
       "name": "sell_priority_fee",
       "type": "int",
       "description": "Sell priority fee (lamports).",
       "required": false
      },
      {
       "name": "buy_tip",
       "type": "int",
       "description": "Buy MEV tip (lamports).",
       "required": false
      },
      {
       "name": "sell_tip",
       "type": "int",
       "description": "Sell MEV tip (lamports).",
       "required": false
      },
      {
       "name": "copy_buys",
       "type": "bool",
       "description": "Copy source buys.",
       "required": false
      },
      {
       "name": "copy_sells",
       "type": "bool",
       "description": "Copy source sells.",
       "required": false
      },
      {
       "name": "follow_sell_percent",
       "type": "bool",
       "description": "Mirror source sell percentages.",
       "required": false
      },
      {
       "name": "buy_protection",
       "type": "bool",
       "description": "Enable buy-side protection.",
       "required": false
      },
      {
       "name": "sell_protection",
       "type": "bool",
       "description": "Enable sell-side protection.",
       "required": false
      },
      {
       "name": "first_interaction_only",
       "type": "bool",
       "description": "Only copy first interaction with a token.",
       "required": false
      },
      {
       "name": "sell_only_copied",
       "type": "bool",
       "description": "Only sell tokens that were copy-bought.",
       "required": false
      },
      {
       "name": "buy_only_once",
       "type": "bool",
       "description": "Buy each token at most once.",
       "required": false
      },
      {
       "name": "skip_deploys",
       "type": "bool",
       "description": "Skip token deploy/creation events.",
       "required": false
      },
      {
       "name": "reverse_mode",
       "type": "bool",
       "description": "Reverse-copy (sell when source buys, etc.).",
       "required": false
      },
      {
       "name": "sell_on_transfer",
       "type": "bool",
       "description": "Sell when source transfers out.",
       "required": false
      },
      {
       "name": "auto_tip",
       "type": "bool",
       "description": "Auto-compute MEV tip.",
       "required": false
      },
      {
       "name": "observe_only",
       "type": "bool",
       "description": "Observe/log without executing.",
       "required": false
      },
      {
       "name": "reverse_min_sell_percent",
       "type": "int",
       "description": "Min sell percent for reverse mode (u8).",
       "required": false
      },
      {
       "name": "start_time",
       "type": "int",
       "description": "Active window start (unix seconds).",
       "required": false
      },
      {
       "name": "end_time",
       "type": "int",
       "description": "Active window end (unix seconds).",
       "required": false
      },
      {
       "name": "max_buy_count",
       "type": "int",
       "description": "Lifetime cap on buys.",
       "required": false
      },
      {
       "name": "max_per_token",
       "type": "int",
       "description": "Max buys per token.",
       "required": false
      },
      {
       "name": "name",
       "type": "string",
       "description": "Config label.",
       "required": false
      },
      {
       "name": "notify_success",
       "type": "bool",
       "description": "Notify on successful copy.",
       "required": false
      },
      {
       "name": "notify_failed",
       "type": "bool",
       "description": "Notify on failed copy.",
       "required": false
      },
      {
       "name": "notify_skipped",
       "type": "bool",
       "description": "Notify on skipped copy.",
       "required": false
      },
      {
       "name": "notify_filtered",
       "type": "bool",
       "description": "Notify on filtered copy.",
       "required": false
      },
      {
       "name": "autosell_profile_id",
       "type": "int",
       "description": "Attach an autosell profile id; <=0 clears it.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"config\":{\"id\":5,\"name\":\"API Config\",\"buy_mode\":\"fixed\",\"buy_fixed_amount_sol\":0.5,\"buy_slippage_bps\":1000,\"sell_slippage_bps\":1000,\"is_active\":true,\"filters\":{},\"blacklist\":[],\"updated_at\":\"...\"}}",
     "responseNotes": "Re-reads and returns the full updated config (config_to_json) after applying all provided fields. Fields are written via several typed DB setters; first failing setter aborts with an error.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "config not found, invalid buy_mode, or any DB setter failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. Invalid buy_mode / not-found / DB errors return HTTP 200 with success:false.",
     "id": "copytrade-update-config"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/config/{id}/toggle",
     "name": "Toggle copy-trade config active",
     "summary": "Activates or deactivates a config. Body must contain the boolean `active`. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "active",
       "type": "bool",
       "description": "true to activate, false to deactivate.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Config 5 activated\"}",
     "responseNotes": "Message is 'Config {id} activated' or 'Config {id} deactivated'. Body is a free-form JSON object; only `active` is read.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "missing `active` field or DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. Missing `active` returns HTTP 200 with success:false.",
     "id": "copytrade-toggle-config"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/config/{id}/reset-count",
     "name": "Reset copy-trade buy count",
     "summary": "Resets the lifetime buy counter (current_buy_count) for a config. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Buy count reset for config 5\"}",
     "responseNotes": "Confirmation message on success.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. No request body.",
     "id": "copytrade-reset-count"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}/blacklist",
     "name": "Get config blacklist",
     "summary": "Returns the token-mint blacklist for a config. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"blacklist\":[\"Es9vMFr...\",\"So11111...\"],\"count\":2}",
     "responseNotes": "blacklist is an array of token-mint strings; count is its length.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-get-blacklist"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/config/{id}/blacklist",
     "name": "Add mint to config blacklist",
     "summary": "Adds a token mint to the config's blacklist (validates the pubkey first). Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "token_mint",
       "type": "string",
       "description": "Token mint pubkey to blacklist.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Added Es9vMFr... to blacklist\"}",
     "responseNotes": "Confirmation message echoing the added mint.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "invalid token_mint address or DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. Invalid mint returns HTTP 200 with success:false.",
     "id": "copytrade-add-blacklist"
    },
    {
     "method": "DELETE",
     "path": "/v1/copy-trade/config/{id}/blacklist",
     "name": "Remove mint from config blacklist",
     "summary": "Removes a token mint from the config's blacklist. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "token_mint",
       "type": "string",
       "description": "Token mint pubkey to remove.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Removed Es9vMFr... from blacklist\"}",
     "responseNotes": "Confirmation message echoing the removed mint.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. Body (token_mint) required even though this is DELETE.",
     "id": "copytrade-remove-blacklist"
    },
    {
     "method": "POST",
     "path": "/v1/copy-trade/config/{id}/blacklist/clear",
     "name": "Clear config blacklist",
     "summary": "Removes all entries from the config's blacklist. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Blacklist cleared\"}",
     "responseNotes": "Confirmation message on success.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted. No request body.",
     "id": "copytrade-clear-blacklist"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}/filters",
     "name": "Get config filters",
     "summary": "Returns the token filter set (mcap, token_age, liquidity ranges + platform allow-list) for a config. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"filters\":{\"mcap\":{\"min\":10000,\"max\":1000000},\"token_age\":null,\"liquidity\":{\"min\":5000,\"max\":null},\"platforms\":[\"pumpfun\"],\"has_filters\":true}}",
     "responseNotes": "mcap/token_age/liquidity are {min,max} range objects (or null); platforms is a string array; has_filters is true if any filter is set.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-get-filters"
    },
    {
     "method": "PUT",
     "path": "/v1/copy-trade/config/{id}/filters",
     "name": "Update config filters",
     "summary": "Replaces the config's filter set from min/max bounds for mcap, token age, and liquidity plus a platform allow-list. A range is set only if its min or max is provided. Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mcap_min",
       "type": "int",
       "description": "Min market cap.",
       "required": false
      },
      {
       "name": "mcap_max",
       "type": "int",
       "description": "Max market cap.",
       "required": false
      },
      {
       "name": "age_min",
       "type": "int",
       "description": "Min token age.",
       "required": false
      },
      {
       "name": "age_max",
       "type": "int",
       "description": "Max token age.",
       "required": false
      },
      {
       "name": "liquidity_min",
       "type": "int",
       "description": "Min liquidity.",
       "required": false
      },
      {
       "name": "liquidity_max",
       "type": "int",
       "description": "Max liquidity.",
       "required": false
      },
      {
       "name": "platforms",
       "type": "string[]",
       "description": "Allowed platforms/DEXes.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Filters updated\",\"filters\":{\"mcap\":{\"min\":10000,\"max\":1000000},\"token_age\":null,\"liquidity\":null,\"platforms\":[\"pumpfun\"]}}",
     "responseNotes": "Echoes the persisted filter JSON. The full filter set is overwritten by the request (omitting a range clears it).",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref. Idempotency-Key header accepted.",
     "id": "copytrade-update-filters"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}/executions",
     "name": "List config executions",
     "summary": "Returns the copy-trade execution history for a config (source vs. our trade legs, signatures, latency, status). Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows (default 50, capped at 200).",
       "required": false
      },
      {
       "name": "offset",
       "type": "int",
       "description": "Row offset for pagination (default 0).",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"executions\":[{\"config_id\":5,\"source_signature\":\"...\",\"source_trade_type\":\"buy\",\"source_dex\":\"pumpfun\",\"token_mint\":\"...\",\"token_symbol\":\"FOO\",\"source_amount_in\":1000000000,\"source_amount_out\":12345,\"our_amount_in\":500000000,\"our_amount_out\":6789,\"our_signature\":\"...\",\"status\":\"success\",\"error_message\":null,\"skip_reason\":null,\"filter_reason\":null,\"source_slot\":123456,\"detected_at_us\":1700000000000,\"execution_started_us\":1700000000050,\"execution_completed_us\":1700000000400,\"confirmed_slot\":123458,\"total_ms\":350,\"swqos_provider\":\"jito\"}],\"count\":1,\"limit\":50,\"offset\":0}",
     "responseNotes": "executions[] holds per-trade audit rows; count is the page length; limit/offset echo the applied paging (limit clamped to ≤200).",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-config-executions"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}/stats",
     "name": "Get config stats",
     "summary": "Returns aggregate execution stats for one config (totals by outcome, success rate, avg latency). Ownership-scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"stats\":{\"total_executions\":120,\"successful\":100,\"failed\":10,\"skipped\":5,\"filtered\":5,\"success_rate\":0.83,\"avg_latency_ms\":342}}",
     "responseNotes": "stats has total_executions, successful, failed, skipped, filtered counts plus computed success_rate and avg_latency_ms.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-config-stats"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/config/{id}/metrics",
     "name": "(Not yet available) Get config live metrics",
     "summary": "Intended to return live runtime metrics for a config. Currently a 501 stub — requires the live copy-trade service not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref.",
     "id": "copytrade-config-metrics"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/user-stats",
     "name": "Get user copy-trade stats",
     "summary": "Returns the caller's aggregate copy-trade stats across all their configs (config counts + execution outcome totals + success rate). Scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"stats\":{\"total_configs\":3,\"active_configs\":2,\"total_executions\":250,\"successful_executions\":210,\"failed_executions\":20,\"skipped_executions\":10,\"filtered_executions\":10,\"success_rate\":0.84}}",
     "responseNotes": "stats aggregates total/active configs and per-outcome execution counts plus computed success_rate across all of the user's configs.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 200,
       "when": "DB failure returned as {success:false,error:string}"
      }
     ],
     "notes": "Requires X-User-Ref.",
     "id": "copytrade-user-stats"
    },
    {
     "method": "GET",
     "path": "/v1/copy-trade/detection-stats",
     "name": "(Not yet available) Get copy-trade detection stats",
     "summary": "Intended to return source-wallet detection metrics from the live engine. Currently a 501 stub — requires the live copy-trade service not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"This endpoint requires the copy-trade service which is not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope. Calls require_user first, so X-User-Ref is still required.",
     "errorCodes": [
      {
       "code": 400,
       "when": "X-User-Ref header missing"
      },
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 501,
       "when": "copy-trade service not wired (always)"
      }
     ],
     "notes": "501 stub (not yet implemented). Requires X-User-Ref.",
     "id": "copytrade-detection-stats"
    }
   ]
  },
  {
   "label": "Trading API: AFK Auto-Buy",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Hands-off auto-buy configs with mint/deployer whitelists, blacklists, sell stages, and smart-wallet rules. automation scope.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/afk/create-config",
     "name": "Create AFK config",
     "summary": "Creates a new AFK (auto-buy) config for the calling tenant with the given name. The config is created as a draft (is_saved/is_active default off) and scoped to the X-User-Ref tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "name",
       "type": "string",
       "description": "Config display name. Must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":42}",
     "responseNotes": "config_id is the new row's integer id. On failure returns HTTP 200 with {\"success\":false,\"error\":\"...\"} (e.g. empty name, DB error).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing name field"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header (tenant scoping). Supports Idempotency-Key header (automation idempotency layer). Domain errors (empty name) return HTTP 200 success:false.",
     "id": "afk-create-config"
    },
    {
     "method": "GET",
     "path": "/v1/afk/configs",
     "name": "List AFK configs",
     "summary": "Returns all AFK configs owned by the calling tenant (resolved from X-User-Ref). Each config is the full serialized AfkConfig object with ~100 filter fields.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":2,\"configs\":[{\"id\":42,\"name\":\"My AFK\",\"is_active\":false,\"is_saved\":true,\"platforms\":[\"pumpfun\"],\"buy_amount_sol\":0.05,\"slippage_bps\":1000,\"mev_protect\":true,\"mcap_min_sol\":10.0,\"mcap_max_sol\":null,\"liquidity_min_sol\":null,\"total_buys\":0,\"total_sells\":0,\"created_at\":\"2026-06-25T12:00:00+00:00\",\"updated_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "configs[] holds the full config object (see config_to_json: id, name, is_active, is_saved, platforms, buy_amount_sol, slippage_bps, mev_protect, plus ~100 filter columns such as mcap/liquidity/token_age/buy_count/bonding_progress/dev_*/holder/socials/sell-stage thresholds, time_window_start/end, total_buys, total_sells, created_at, updated_at). count = number of configs.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header (tenant scoping).",
     "id": "afk-list-configs"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}",
     "name": "Get AFK config",
     "summary": "Returns a single AFK config by id, scoped to the calling tenant (telegram_id ownership enforced in the DB query). Returns success:false if the config does not belong to the tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config\":{\"id\":42,\"name\":\"My AFK\",\"is_active\":false,\"is_saved\":true,\"platforms\":[\"pumpfun\"],\"buy_amount_sol\":0.05,\"slippage_bps\":1000,\"mev_protect\":true,\"created_at\":\"2026-06-25T12:00:00+00:00\",\"updated_at\":\"2026-06-25T12:00:00+00:00\"}}",
     "responseNotes": "config is the full AfkConfig object (same shape as list_configs items, ~100 filter fields). Not-found / not-owned returns HTTP 200 {\"success\":false,\"error\":\"Config not found\"}.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Config-not-found returns HTTP 200 success:false (not 404).",
     "id": "afk-get-config"
    },
    {
     "method": "PUT",
     "path": "/v1/afk/config/{id}",
     "name": "Update AFK config field",
     "summary": "Updates a single field on an AFK config (one field per call) for the calling tenant. The field name is resolved against the config's column schema (f64/i32/i64/bool/str) plus special handlers for platforms, name, name_exact, name_contains, and time_window_start/end.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "field",
       "type": "string",
       "description": "Column/filter name to update (e.g. buy_amount_sol, mcap_min_sol, platforms, name, time_window_start). Unknown names are rejected.",
       "required": true
      },
      {
       "name": "value",
       "type": "string",
       "description": "New value as a string; coerced to the column type. \"null\" or \"\" clears nullable filters. For platforms: comma-separated list. For time_window_*: HH:MM or HH:MM:SS.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"field\":\"mcap_min_sol\",\"value\":12.5}",
     "responseNotes": "Echoes field and the coerced value (type depends on the column; platforms echoes an array). Invalid value coercion or unknown field returns HTTP 200 success:false with a descriptive error string.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing field or value"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key. Validation errors (bad number/bool/time, unknown field) return HTTP 200 success:false. No utoipa annotation but route is live.",
     "id": "afk-update-config"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/toggle",
     "name": "Toggle AFK config active",
     "summary": "Activates or deactivates an AFK config for the calling tenant. Only configs with is_saved=true can be toggled active (drafts are rejected) — mirrors the UI auto-save-on-activate gate. The engine cache only picks up the flip on next bot restart or UI write.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "active",
       "type": "boolean",
       "description": "Desired active state.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":42,\"is_active\":true}",
     "responseNotes": "is_active is the new state returned by the UPDATE. If the config is not found, not owned, or not saved, returns HTTP 200 {\"success\":false,\"error\":\"Config not found or not saved\"}.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing active field"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key. is_saved=true gate prevents activating drafts. Engine cache not refreshed live (tracked under task #126).",
     "id": "afk-toggle-config"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}",
     "name": "Delete AFK config",
     "summary": "Deletes an AFK config (and its associated lists/stages via DB cascade) for the calling tenant. Ownership enforced by telegram_id in the DB query.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Config #42 deleted\"}",
     "responseNotes": "message confirms deletion. DB error returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-delete-config"
    },
    {
     "method": "GET",
     "path": "/v1/afk/executions",
     "name": "List AFK executions",
     "summary": "Returns the calling tenant's AFK auto-buy execution log (most recent first), optionally filtered by config_id. Each row records the attempted buy, signature, success flag and market snapshot.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows to return. Default 20, capped at 100.",
       "required": false
      },
      {
       "name": "config_id",
       "type": "int",
       "description": "Filter executions to a single config.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"executions\":[{\"id\":7,\"config_id\":42,\"mint\":\"So111...\",\"dex_type\":\"pumpfun\",\"buy_amount_sol\":0.05,\"signature\":\"5xY...\",\"success\":true,\"error_message\":null,\"mcap_at_buy\":12000.0,\"liquidity_at_buy\":30.0,\"executed_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "executions[] fields: id, config_id, mint, dex_type, buy_amount_sol, signature, success, error_message, mcap_at_buy, liquidity_at_buy, executed_at. count = rows returned.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. limit is hard-capped server-side at 100.",
     "id": "afk-executions"
    },
    {
     "method": "POST",
     "path": "/v1/afk/simulate",
     "name": "(Not yet available) Simulate AFK filters (stub)",
     "summary": "Intended to evaluate a config's filters against a given mint. Currently not implemented in the multi-tenant API — RPC/indexer are not wired into V1State.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "mint",
       "type": "string",
       "description": "Token mint to simulate against.",
       "required": true
      },
      {
       "name": "config_id",
       "type": "int",
       "description": "AFK config id to evaluate.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":false,\"error\":{\"code\":\"not_implemented\",\"message\":\"Simulate requires rpc/indexer which are not yet wired to V1State\"}}",
     "responseNotes": "Always returns the not_implemented envelope at HTTP 200; never runs a real simulation.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "501 stub (not yet implemented) — returns {success:false, error.code:not_implemented} at HTTP 200, not a real 501 status. Requires X-User-Ref header.",
     "id": "afk-simulate"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/whitelist-mints",
     "name": "List whitelist mints",
     "summary": "Returns the mint-address whitelist for an AFK config (tenant-scoped). When set, only these mints are eligible for auto-buy.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"mints\":[{\"id\":3,\"address\":\"So111...\",\"created_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "mints[] fields: id, address, created_at. count = entries.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-whitelist-mints"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/whitelist-mints",
     "name": "Add whitelist mint",
     "summary": "Adds a mint address to an AFK config's whitelist (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "address",
       "type": "string",
       "description": "Mint address to whitelist. Must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Added mint So111...\"}",
     "responseNotes": "Empty address returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing address"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-whitelist-mint"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/whitelist-mints",
     "name": "Clear whitelist mints",
     "summary": "Removes all whitelist mint entries from an AFK config (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All whitelist mints cleared\"}",
     "responseNotes": "Clears the entire list for the config.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-whitelist-mints"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/whitelist-deployers",
     "name": "List whitelist deployers",
     "summary": "Returns the deployer-address whitelist for an AFK config (tenant-scoped). When set, only tokens from these deployers are eligible.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"deployers\":[{\"id\":4,\"address\":\"Dev111...\",\"label\":\"trusted dev\",\"created_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "deployers[] fields: id, address, label (nullable), created_at. count = entries.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-whitelist-deployers"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/whitelist-deployers",
     "name": "Add whitelist deployer",
     "summary": "Adds a deployer address (with optional label) to an AFK config's deployer whitelist (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "address",
       "type": "string",
       "description": "Deployer wallet address. Must be non-empty.",
       "required": true
      },
      {
       "name": "label",
       "type": "string",
       "description": "Optional human label for the deployer.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Added deployer Dev111...\"}",
     "responseNotes": "Empty address returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing address"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-whitelist-deployer"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/whitelist-deployers",
     "name": "Clear whitelist deployers",
     "summary": "Removes all deployer whitelist entries from an AFK config (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All whitelist deployers cleared\"}",
     "responseNotes": "Clears the entire deployer list for the config.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-whitelist-deployers"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/blacklist",
     "name": "Get blacklist (tokens + devs)",
     "summary": "Returns both the token-address and deployer-address blacklists for an AFK config (tenant-scoped) in a single response.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"token_addresses\":[\"Mint111...\"],\"token_count\":1,\"dev_addresses\":[\"Dev111...\"],\"dev_count\":1}",
     "responseNotes": "token_addresses + token_count and dev_addresses + dev_count are returned together (arrays of address strings).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-blacklist"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/blacklist",
     "name": "Add to blacklist",
     "summary": "Adds a token or deployer address to an AFK config's blacklist (tenant-scoped). list_type selects which list.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "list_type",
       "type": "string",
       "description": "Which blacklist: must be \"token\" or \"dev\".",
       "required": true
      },
      {
       "name": "address",
       "type": "string",
       "description": "Address to blacklist. Must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Blacklisted Mint111... (token)\"}",
     "responseNotes": "Empty address or list_type not in {token,dev} returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing fields"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-to-blacklist"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/blacklist",
     "name": "Clear blacklist",
     "summary": "Clears both the token and deployer blacklists for an AFK config (tenant-scoped) in one call.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All blacklist entries cleared\"}",
     "responseNotes": "Clears both token and dev blacklist lists (runs both clears concurrently).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-blacklist"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/blacklist-words",
     "name": "List blacklist words",
     "summary": "Returns the name/symbol blacklist words for an AFK config (tenant-scoped). Tokens whose name/symbol match a word are skipped.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"words\":[{\"id\":9,\"word\":\"scam\",\"created_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "words[] fields: id, word, created_at. count = entries.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-blacklist-words"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/blacklist-words",
     "name": "Add blacklist words",
     "summary": "Adds one or more words to an AFK config's name/symbol blacklist (tenant-scoped). Returns how many were newly added vs submitted (dedup).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "words",
       "type": "string[]",
       "description": "Array of words to blacklist. Must be non-empty.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"added\":2,\"submitted\":3}",
     "responseNotes": "added = newly inserted rows; submitted = number of words in the request (difference = duplicates skipped). Empty array returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing words array"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-blacklist-words"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/blacklist-words",
     "name": "Clear blacklist words",
     "summary": "Removes all blacklist words from an AFK config (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All blacklist words cleared\"}",
     "responseNotes": "Clears the entire word list for the config.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-blacklist-words"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/sell-stages",
     "name": "List sell stages",
     "summary": "Returns the laddered auto-sell stages for an AFK config (tenant-scoped). Each stage sells a percentage at a price multiplier.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"stages\":[{\"id\":11,\"stage_order\":1,\"sell_pct\":50.0,\"multiplier\":2.0,\"created_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "stages[] fields: id, stage_order, sell_pct, multiplier, created_at. count = stages.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-sell-stages"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/sell-stages",
     "name": "Add sell stage",
     "summary": "Adds an auto-sell stage (sell_pct at a price multiplier) to an AFK config (tenant-scoped). Validates sell_pct in (0,100] and multiplier > 0.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "sell_pct",
       "type": "number",
       "description": "Percent of holdings to sell at this stage. Must be > 0 and <= 100.",
       "required": true
      },
      {
       "name": "multiplier",
       "type": "number",
       "description": "Price multiplier (e.g. 2.0 = 2x) that triggers this stage. Must be > 0.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Added sell stage: 50% at 2x\"}",
     "responseNotes": "Out-of-range sell_pct or non-positive multiplier returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing fields"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-sell-stage"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/remove-sell-stage",
     "name": "Remove sell stage",
     "summary": "Removes a single auto-sell stage from an AFK config by its stage_order (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "stage_order",
       "type": "int",
       "description": "Order index of the stage to remove.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Removed sell stage #1\"}",
     "responseNotes": "Removes the stage matching stage_order. DB error returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing stage_order"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key. POST (not DELETE) because it takes a body.",
     "id": "afk-remove-sell-stage"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/sell-stages",
     "name": "Clear sell stages",
     "summary": "Removes all auto-sell stages from an AFK config (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All sell stages cleared\"}",
     "responseNotes": "Clears every sell stage for the config.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-sell-stages"
    },
    {
     "method": "GET",
     "path": "/v1/afk/config/{id}/smart-wallets",
     "name": "List smart wallets",
     "summary": "Returns the smart-wallet list for an AFK config (tenant-scoped). Used with smart_wallet_threshold to require buys from N tracked wallets before auto-buying.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"smart_wallets\":[{\"id\":5,\"address\":\"Whale111...\",\"label\":\"alpha\",\"created_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "smart_wallets[] fields: id, address (DB column wallet_address), label (nullable), created_at. count = entries.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header.",
     "id": "afk-get-smart-wallets"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/smart-wallets",
     "name": "Add smart wallet",
     "summary": "Adds a smart wallet (with optional label) to an AFK config's tracked wallet list (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "address",
       "type": "string",
       "description": "Wallet address to track. Must be non-empty.",
       "required": true
      },
      {
       "name": "label",
       "type": "string",
       "description": "Optional human label for the wallet.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Added smart wallet Whale111...\"}",
     "responseNotes": "Empty address returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing address"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-add-smart-wallet"
    },
    {
     "method": "POST",
     "path": "/v1/afk/config/{id}/remove-smart-wallet",
     "name": "Remove smart wallet",
     "summary": "Removes a single smart wallet from an AFK config by address (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "address",
       "type": "string",
       "description": "Wallet address to remove.",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Removed smart wallet Whale111...\"}",
     "responseNotes": "Removes the matching wallet. DB error returns HTTP 200 success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed body / missing address"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key. POST (not DELETE) because it takes a body.",
     "id": "afk-remove-smart-wallet"
    },
    {
     "method": "DELETE",
     "path": "/v1/afk/config/{id}/smart-wallets",
     "name": "Clear smart wallets",
     "summary": "Removes all smart wallets from an AFK config (tenant-scoped).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AFK config id."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"All smart wallets cleared\"}",
     "responseNotes": "Clears the entire smart-wallet list for the config.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 402,
       "when": "insufficient credits"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 429,
       "when": "rate limit exceeded"
      }
     ],
     "notes": "Requires X-User-Ref header. Supports Idempotency-Key.",
     "id": "afk-clear-smart-wallets"
    }
   ]
  },
  {
   "label": "Trading API: Sniper & Auto-Sell",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). New-launch sniping plus automated take-profit / stop-loss auto-sell. automation scope; per-tenant.",
   "endpoints": [
    {
     "method": "POST",
     "path": "/v1/sniper/create-config",
     "name": "Create sniper config",
     "summary": "Creates a new auto-sniper/TG-Auto config for the calling tenant and immediately marks it saved (is_saved=true). The target wallet is verified to belong to the caller before creation so a tenant cannot snipe-buy through another tenant's wallet.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "name",
       "type": "string",
       "description": "Display name for the config",
       "required": true
      },
      {
       "name": "wallet_id",
       "type": "int",
       "description": "Wallet to buy with; must belong to caller. Defaults to the caller's most recent active wallet when omitted",
       "required": false
      },
      {
       "name": "buy_amount",
       "type": "int",
       "description": "Buy size in lamports; must be positive",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":12,\"name\":\"my snipe\",\"wallet_id\":3,\"buy_amount\":1000000}",
     "responseNotes": "config_id is the new row id; echoes name, wallet_id (resolved), and buy_amount (lamports).",
     "errorCodes": [
      {
       "code": 422,
       "when": "missing/invalid body, no_active_wallet, buy_amount <= 0, or wallet_id does not belong to caller (returned as success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB unreachable"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header (automation idempotency layer).",
     "id": "sniper-create-config"
    },
    {
     "method": "GET",
     "path": "/v1/sniper/configs",
     "name": "List sniper configs",
     "summary": "Returns all sniper/TG-Auto/X-Auto configs owned by the calling tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"configs\":[{\"id\":12,\"name\":\"my snipe\",\"wallet_id\":3,\"is_active\":false,\"is_saved\":true,\"buy_amount\":1000000,\"buy_amount_sol\":0.001,\"buy_slippage_bps\":1000,\"buy_priority_fee\":null,\"buy_tip\":null,\"auto_tip\":true,\"buy_protection\":false,\"max_buy_count\":null,\"current_buy_count\":0,\"max_per_token\":1,\"filters\":{},\"notify_success\":true,\"notify_failed\":true,\"notify_filtered\":false,\"autosell_profile_id\":null,\"x_handle\":null,\"x_user_id\":null,\"monitor_posts\":true,\"monitor_replies\":false,\"monitor_reposts\":false,\"created_at\":\"...\",\"updated_at\":\"...\"}],\"count\":1}",
     "responseNotes": "configs is an array of full config objects (see config_to_json fields incl. buy_amount lamports + buy_amount_sol, filters JSON, X-Auto monitor flags); count is the array length.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error fetching configs"
      }
     ],
     "id": "sniper-list-configs"
    },
    {
     "method": "GET",
     "path": "/v1/sniper/config/{id}",
     "name": "Get sniper config",
     "summary": "Returns one sniper config (scoped to the caller) plus its channel count and a human source summary. Returns not-found if the id does not belong to the caller.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config\":{\"id\":12,\"name\":\"my snipe\",\"wallet_id\":3,\"is_active\":false,\"is_saved\":true,\"buy_amount\":1000000,\"buy_amount_sol\":0.001,\"filters\":{},\"x_handle\":null,\"monitor_posts\":true,\"created_at\":\"...\",\"updated_at\":\"...\"},\"channel_count\":2,\"source_summary\":\"2 channels\"}",
     "responseNotes": "config is the full config object; channel_count is the number of attached TG channels; source_summary is a derived human label.",
     "errorCodes": [
      {
       "code": 404,
       "when": "config id not found for caller (returned as success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "id": "sniper-get-config"
    },
    {
     "method": "PUT",
     "path": "/v1/sniper/config/{id}",
     "name": "Update sniper config fields",
     "summary": "Bulk-updates arbitrary config fields from a string-keyed map; each field is applied individually and the response reports which succeeded vs failed. Config ownership is verified before any update.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "fields",
       "type": "object",
       "description": "Map of field name -> string value to apply (e.g. {\"buy_slippage_bps\":\"1500\",\"max_buy_count\":\"5\"}). Must be non-empty",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"updated_fields\":[\"buy_slippage_bps\"],\"config_id\":12}",
     "responseNotes": "On full success returns updated_fields. On partial failure returns success:false with both updated_fields and errors (array of \"field: message\").",
     "errorCodes": [
      {
       "code": 422,
       "when": "empty fields map or invalid body (success:false error envelope)"
      },
      {
       "code": 404,
       "when": "config id not found for caller"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header. Per-field failures return success:false with an errors array (HTTP 200 envelope).",
     "id": "sniper-update-config"
    },
    {
     "method": "DELETE",
     "path": "/v1/sniper/config/{id}",
     "name": "Delete sniper config",
     "summary": "Deletes a sniper config owned by the caller (ownership enforced at the SQL layer via telegram_id).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Config 12 deleted\"}",
     "responseNotes": "message confirms the deleted config id.",
     "errorCodes": [
      {
       "code": 422,
       "when": "delete failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header.",
     "id": "sniper-delete-config"
    },
    {
     "method": "POST",
     "path": "/v1/sniper/config/{id}/toggle",
     "name": "Toggle sniper config active",
     "summary": "Activates or deactivates a sniper config (turns the snipe worker on/off for it). Ownership verified before toggle.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "active",
       "type": "boolean",
       "description": "true to activate, false to deactivate",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":12,\"is_active\":true}",
     "responseNotes": "is_active echoes the requested state.",
     "errorCodes": [
      {
       "code": 404,
       "when": "config id not found for caller"
      },
      {
       "code": 422,
       "when": "invalid body or toggle failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header.",
     "id": "sniper-toggle-config"
    },
    {
     "method": "POST",
     "path": "/v1/sniper/config/{id}/reset-count",
     "name": "Reset sniper buy count",
     "summary": "Resets the config's current_buy_count back to 0 (clears the lifetime buy cap counter so a capped config resumes buying). Ownership verified first.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config_id\":12,\"message\":\"Buy count reset to 0\"}",
     "responseNotes": "Confirms current_buy_count reset to 0 for the config.",
     "errorCodes": [
      {
       "code": 404,
       "when": "config id not found for caller"
      },
      {
       "code": 422,
       "when": "reset failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header.",
     "id": "sniper-reset-count"
    },
    {
     "method": "GET",
     "path": "/v1/sniper/config/{id}/channels",
     "name": "List sniper config channels",
     "summary": "Lists the Telegram channels attached to a sniper config (the sources it monitors for token mints).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config_id\":12,\"channels\":[{\"id\":4,\"config_id\":12,\"channel_username\":\"somechannel\",\"is_preset\":false,\"added_at\":\"...\",\"resolved_chat_id\":-1001234567890}],\"count\":1}",
     "responseNotes": "channels is an array of channel objects (id, channel_username, is_preset, added_at, resolved_chat_id); count is the length.",
     "errorCodes": [
      {
       "code": 422,
       "when": "fetch failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "id": "sniper-list-channels"
    },
    {
     "method": "POST",
     "path": "/v1/sniper/config/{id}/channels",
     "name": "Add sniper config channel",
     "summary": "Attaches a Telegram channel to a sniper config. The username is normalized (leading @ stripped, lowercased) before storage.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "channel_username",
       "type": "string",
       "description": "Telegram channel username to monitor (with or without leading @). Required, non-empty",
       "required": true
      },
      {
       "name": "is_preset",
       "type": "boolean",
       "description": "Whether this is a curated preset channel. Defaults to false",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":12,\"channel_username\":\"somechannel\",\"is_preset\":false}",
     "responseNotes": "channel_username is echoed in normalized form (no @, lowercased).",
     "errorCodes": [
      {
       "code": 422,
       "when": "empty channel_username, invalid body, or add failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header.",
     "id": "sniper-add-channel"
    },
    {
     "method": "DELETE",
     "path": "/v1/sniper/config/{id}/channels",
     "name": "Remove sniper config channel",
     "summary": "Detaches a Telegram channel from a sniper config. Channel identified by username in the request body (normalized: @ stripped, lowercased).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "channel_username",
       "type": "string",
       "description": "Channel username to remove (with or without leading @). Required, non-empty",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":12,\"removed_channel\":\"somechannel\"}",
     "responseNotes": "removed_channel is echoed in normalized form (no @, lowercased).",
     "errorCodes": [
      {
       "code": 422,
       "when": "empty channel_username, invalid body, or remove failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header. Channel selector is in the JSON body, not the path.",
     "id": "sniper-remove-channel"
    },
    {
     "method": "GET",
     "path": "/v1/sniper/executions",
     "name": "List sniper executions",
     "summary": "Returns sniper execution history (detected/bought/filtered/failed events). With ?config_id it returns that config's history; without it, fans out across only the caller's own configs (user-scoped, never global).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [
      {
       "name": "config_id",
       "type": "int",
       "description": "Restrict to a single config's executions. When omitted, returns recent executions across all of the caller's configs",
       "required": false
      },
      {
       "name": "limit",
       "type": "int",
       "description": "Max rows to return. Defaults to 20, capped at 100",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"config_id\":12,\"executions\":[{\"id\":99,\"config_id\":12,\"source_type\":\"tg\",\"token_mint\":\"...\",\"token_symbol\":\"FOO\",\"amount_in\":1000000,\"amount_out\":123456,\"signature\":\"...\",\"status\":\"success\",\"error_message\":null,\"filter_reason\":null,\"channel_username\":\"somechannel\",\"message_id\":42,\"x_post_id\":null,\"x_post_type\":null,\"detected_at\":\"...\",\"executed_at\":\"...\",\"created_at\":\"...\"}],\"count\":1}",
     "responseNotes": "executions is an array of execution objects (see execution_to_json). config_id is only present in the response when supplied in the query. count is the array length.",
     "errorCodes": [
      {
       "code": 422,
       "when": "fetch failed (success:false error envelope)"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "id": "sniper-executions"
    },
    {
     "method": "PUT",
     "path": "/v1/sniper/config/{id}/x-settings",
     "name": "Update sniper X-Auto settings",
     "summary": "Updates X (Twitter) monitoring settings on a sniper config: sets/removes the X handle as a source, sets/clears x_user_id, and toggles monitor_posts/replies/reposts. Each provided field is applied individually with per-field success/error reporting. Ownership verified first.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "Sniper config ID (caller-owned)"
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "x_handle",
       "type": "string",
       "description": "X handle to monitor. Empty string removes the X source; non-empty sets it",
       "required": false
      },
      {
       "name": "x_user_id",
       "type": "string",
       "description": "Numeric X user id. Empty string clears it; non-empty sets it",
       "required": false
      },
      {
       "name": "monitor_posts",
       "type": "boolean",
       "description": "Whether to monitor original posts",
       "required": false
      },
      {
       "name": "monitor_replies",
       "type": "boolean",
       "description": "Whether to monitor replies",
       "required": false
      },
      {
       "name": "monitor_reposts",
       "type": "boolean",
       "description": "Whether to monitor reposts",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"config_id\":12,\"updated_fields\":[\"x_handle\",\"monitor_posts\"]}",
     "responseNotes": "updated_fields lists applied changes (e.g. \"x_handle\", \"x_handle (removed)\", \"x_user_id (cleared)\"). On partial failure returns success:false with an additional errors array.",
     "errorCodes": [
      {
       "code": 422,
       "when": "no X fields provided, invalid body, or all updates failed (success:false error envelope)"
      },
      {
       "code": 404,
       "when": "config id not found for caller"
      },
      {
       "code": 401,
       "when": "missing/invalid API key"
      },
      {
       "code": 403,
       "when": "key lacks automation scope"
      },
      {
       "code": 503,
       "when": "DB error"
      }
     ],
     "notes": "Mutation — supports Idempotency-Key header. Per-field failures return success:false with an errors array (HTTP 200 envelope).",
     "id": "sniper-update-x-settings"
    },
    {
     "method": "POST",
     "path": "/v1/autosell/create",
     "name": "Create AutoSell profile",
     "summary": "Creates a new named AutoSell profile for the calling tenant. The user is resolved from the X-User-Ref header (tenancy); the new profile starts with default rules and returns its numeric id.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "name",
       "type": "string",
       "description": "Profile display name. Must be non-empty (trimmed).",
       "required": true
      }
     ],
     "responseExample": "{\"success\":true,\"profile_id\":42}",
     "responseNotes": "On success returns the new profile_id (i32). On failure returns {\"success\":false,\"error\":\"<message>\"} with HTTP 200 (e.g. empty name, DB error).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Mutation under the automation group — supports Idempotency-Key header (tenancy idempotency_layer). Tenant resolved via X-User-Ref. Validation/DB errors return HTTP 200 with success:false.",
     "id": "autosell-create"
    },
    {
     "method": "GET",
     "path": "/v1/autosell/profiles",
     "name": "List AutoSell profiles",
     "summary": "Lists all AutoSell profiles owned by the calling tenant (resolved from X-User-Ref). Returns a count plus the full serialized profile objects.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"count\":1,\"profiles\":[{\"id\":42,\"name\":\"Moonbag\",\"is_active\":true,\"is_saved\":true,\"fixed_rules\":[],\"trailing_enabled\":false,\"trailing_activation_pct\":0.0,\"trailing_drawdown_pct\":0.0,\"moonbag_pct\":0,\"expiry_seconds\":0,\"sell_on_expiry\":false,\"sell_slippage_bps\":1000,\"mev_protect\":true,\"priority_fee_lamports\":null,\"gas_cu_price\":null,\"sell_on_fee_claim\":false,\"fee_claim_sell_percent\":null,\"fee_claim_min_sol\":null,\"created_at\":\"2026-06-25T12:00:00+00:00\",\"updated_at\":\"2026-06-25T12:00:00+00:00\"}]}",
     "responseNotes": "profiles[] is the full profile object (same shape as get/update). count is profiles.len(). Errors return HTTP 200 with success:false.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Tenant resolved via X-User-Ref. Read-only (no idempotency needed).",
     "id": "autosell-list-profiles"
    },
    {
     "method": "GET",
     "path": "/v1/autosell/profile/{id}",
     "name": "Get AutoSell profile",
     "summary": "Fetches a single AutoSell profile by id, scoped to the calling tenant (resolved from X-User-Ref). 404-equivalent when the profile does not exist or is not owned by the tenant.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AutoSell profile id (i32)."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"profile\":{\"id\":42,\"name\":\"Moonbag\",\"is_active\":true,\"is_saved\":true,\"fixed_rules\":[],\"trailing_enabled\":false,\"trailing_activation_pct\":0.0,\"trailing_drawdown_pct\":0.0,\"moonbag_pct\":0,\"expiry_seconds\":0,\"sell_on_expiry\":false,\"sell_slippage_bps\":1000,\"mev_protect\":true,\"priority_fee_lamports\":null,\"gas_cu_price\":null,\"sell_on_fee_claim\":false,\"fee_claim_sell_percent\":null,\"fee_claim_min_sol\":null,\"created_at\":\"2026-06-25T12:00:00+00:00\",\"updated_at\":\"2026-06-25T12:00:00+00:00\"}}",
     "responseNotes": "profile holds all profile fields. When the profile is missing/not owned, returns {\"success\":false,\"error\":\"Profile not found\"} (HTTP 200).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Ownership enforced in DB query (id + tenant user). Not-found returns success:false at HTTP 200.",
     "id": "autosell-get-profile"
    },
    {
     "method": "PUT",
     "path": "/v1/autosell/profile/{id}",
     "name": "Update AutoSell profile",
     "summary": "Partially updates an AutoSell profile (all body fields optional); only provided fields are written. Scoped to the calling tenant (X-User-Ref). Returns the full updated profile.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AutoSell profile id (i32)."
      }
     ],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "name",
       "type": "string",
       "description": "New profile name. Must be non-empty (trimmed) if provided.",
       "required": false
      },
      {
       "name": "fixed_rules",
       "type": "object",
       "description": "Fixed TP/SL ladder rules (raw JSON, stored as-is).",
       "required": false
      },
      {
       "name": "trailing_enabled",
       "type": "bool",
       "description": "Enable/disable trailing stop. Triggers a trailing update if any trailing_* field is present.",
       "required": false
      },
      {
       "name": "trailing_activation_pct",
       "type": "number",
       "description": "Profit % at which the trailing stop activates.",
       "required": false
      },
      {
       "name": "trailing_drawdown_pct",
       "type": "number",
       "description": "Drawdown % from peak that triggers the sell.",
       "required": false
      },
      {
       "name": "moonbag_pct",
       "type": "int",
       "description": "Percent of position to retain as a moonbag (not sold).",
       "required": false
      },
      {
       "name": "expiry_seconds",
       "type": "int",
       "description": "Seconds until the profile auto-expires (0 = no expiry).",
       "required": false
      },
      {
       "name": "sell_slippage_bps",
       "type": "int",
       "description": "Sell slippage tolerance in basis points.",
       "required": false
      },
      {
       "name": "mev_protect",
       "type": "bool",
       "description": "Route auto-sells through MEV-protected providers.",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"profile\":{\"id\":42,\"name\":\"Moonbag\",\"is_active\":true,\"is_saved\":true,\"fixed_rules\":[],\"trailing_enabled\":true,\"trailing_activation_pct\":50.0,\"trailing_drawdown_pct\":10.0,\"moonbag_pct\":20,\"expiry_seconds\":0,\"sell_on_expiry\":false,\"sell_slippage_bps\":1500,\"mev_protect\":true,\"priority_fee_lamports\":null,\"gas_cu_price\":null,\"sell_on_fee_claim\":false,\"fee_claim_sell_percent\":null,\"fee_claim_min_sol\":null,\"created_at\":\"2026-06-25T12:00:00+00:00\",\"updated_at\":\"2026-06-25T12:05:00+00:00\"}}",
     "responseNotes": "Each provided field is applied via a separate DB update; on any sub-update error returns success:false immediately. On success re-fetches and returns the full updated profile.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Same path as GET/DELETE (multi-method route). Supports Idempotency-Key header (automation idempotency_layer). Empty name, missing tenant, or DB errors return HTTP 200 with success:false.",
     "id": "autosell-update-profile"
    },
    {
     "method": "DELETE",
     "path": "/v1/autosell/profile/{id}",
     "name": "Delete AutoSell profile",
     "summary": "Deletes an AutoSell profile by id, scoped to the calling tenant (X-User-Ref). Returns success:false if no matching owned profile was found.",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [
      {
       "name": "id",
       "type": "int",
       "description": "AutoSell profile id (i32) to delete."
      }
     ],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"message\":\"Profile #42 deleted\"}",
     "responseNotes": "On a no-op delete (profile missing/not owned) returns {\"success\":false,\"error\":\"Profile not found\"} (HTTP 200).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Same path as GET/PUT (multi-method route). Ownership enforced in the DELETE query (id + tenant user).",
     "id": "autosell-delete-profile"
    },
    {
     "method": "POST",
     "path": "/v1/autosell/attach",
     "name": "Attach AutoSell profile",
     "summary": "Attaches an AutoSell profile either as the tenant's global default (source=global) or to a specific AFK config (source=afk, requires source_config_id). All operations scoped to the calling tenant (X-User-Ref).",
     "authRequired": true,
     "scope": "automation",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [
      {
       "name": "profile_id",
       "type": "int",
       "description": "AutoSell profile id to attach.",
       "required": true
      },
      {
       "name": "source",
       "type": "string",
       "description": "Attach target. Valid values: \"global\" (set as tenant default) or \"afk\" (attach to an AFK config).",
       "required": true
      },
      {
       "name": "source_config_id",
       "type": "int",
       "description": "Target AFK config id. Required when source=\"afk\"; ignored for source=\"global\".",
       "required": false
      }
     ],
     "responseExample": "{\"success\":true,\"message\":\"Profile #42 set as global default\"}",
     "responseNotes": "source=global returns 'set as global default'; source=afk returns 'attached to AFK config #N'. Unknown source, missing source_config_id for afk, or DB errors return {\"success\":false,\"error\":\"...\"} (HTTP 200).",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing/invalid X-API-Key"
      },
      {
       "code": 403,
       "when": "API key lacks automation scope"
      },
      {
       "code": 422,
       "when": "malformed JSON body"
      },
      {
       "code": 503,
       "when": "service unavailable / DB-only mode"
      }
     ],
     "notes": "Mutation under automation group — supports Idempotency-Key header. source=\"afk\" without source_config_id, or any source other than global/afk, returns success:false at HTTP 200.",
     "id": "autosell-attach-profile"
    }
   ]
  },
  {
   "label": "Trading API: System",
   "groupSummary": "Auth: send your key as `Authorization: Bearer <key>` (the trading API uses Bearer, NOT the X-API-Key header used by the data API). Service health and a per-key usage / quota snapshot.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/v1/health",
     "name": "Health check",
     "summary": "Public, unauthenticated liveness/readiness probe. Returns DB reachability plus build/version markers; with ?deep=1 it also probes RPC reachability (cached ~5s) and treasury config. Returns 503 only when the DB is unreachable so load balancers drain the node.",
     "authRequired": false,
     "scope": "public",
     "pathParams": [],
     "queryParams": [
      {
       "name": "deep",
       "type": "string",
       "description": "When set to 1/true/yes/on, also probes RPC reachability (rpc_ok, cached at most once per ~5s) and treasury_configured, and folds rpc_ok into status. Omit for the cheap single DB-ping path.",
       "required": false
      }
     ],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"status\":\"ok\",\"db_ok\":true,\"workers_mode\":\"on\",\"build_version\":\"0.7.441\",\"crate_version\":\"1.0.0\",\"rpc_ok\":true,\"treasury_configured\":true}}",
     "responseNotes": "data.status is \"ok\" or \"degraded\". db_ok = SELECT 1 succeeded. workers_mode is \"on\"|\"off\". build_version = bot BOT_VERSION deploy marker; crate_version = CARGO_PKG_VERSION. rpc_ok and treasury_configured only present when ?deep=1; in deep mode status becomes \"ok\" only if both db_ok && rpc_ok. HTTP 200 whenever db_ok (even if rpc degraded); HTTP 503 only when DB is down.",
     "errorCodes": [
      {
       "code": 503,
       "when": "DB unreachable (db_ok=false); body still has success:true with data.status=degraded"
      }
     ],
     "id": "system-health"
    },
    {
     "method": "GET",
     "path": "/v1/usage",
     "name": "Usage / credit snapshot",
     "summary": "App-level credit and quota snapshot for the calling X-API-Key. Free (0 credits), no X-User-Ref / tenancy — reports the key's plan, scopes, owner wallet, and credit window usage. Read scope.",
     "authRequired": true,
     "scope": "read",
     "pathParams": [],
     "queryParams": [],
     "bodyParams": [],
     "responseExample": "{\"success\":true,\"data\":{\"name\":\"Stryke-terminal\",\"tier\":\"paid\",\"scopes\":[\"read\",\"trade\"],\"owner_wallet\":\"7xKW...\",\"credits_monthly\":100000,\"credits_used\":1234,\"credits_remaining\":98766,\"unlimited\":false,\"window_days\":30,\"window_resets_at\":\"2026-07-26T00:00:00+00:00\",\"rate_per_min\":600,\"rate_burst\":120}}",
     "responseNotes": "data.credits_monthly is null for unlimited keys (then unlimited=true and credits_remaining is not meaningful). credits_used/credits_remaining are scoped to the rolling window of window_days, resetting at window_resets_at (RFC3339). rate_per_min and rate_burst are the key's token-bucket limits. On unknown app returns 200 with success:false error.code=not_found; on DB error returns 200 with success:false error.code=db.",
     "errorCodes": [
      {
       "code": 401,
       "when": "missing or invalid X-API-Key (require_app)"
      },
      {
       "code": 403,
       "when": "key lacks read scope"
      },
      {
       "code": 200,
       "when": "app not found or DB error — body has success:false with error.code not_found|db"
      }
     ],
     "id": "system-usage"
    }
   ]
  },
  {
   "label": "Programs",
   "groupSummary": "On-chain program (smart-contract) intel — account, upgrade state, activity, type mix.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/programs/{programId}",
     "name": "Program (smart-contract) intel",
     "summary": "Program account + upgrade state, activity sampling (tx/min, fail%, last-active), a tx-type mix to label an unknown program, and recent decoded transactions. Consolidates a direct-Stryke's node layer program page into one allow-listed call.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "programId",
       "type": "string",
       "description": "Program (smart-contract) address, base58."
      }
     ],
     "responseExample": "{\"success\":true,\"programId\":\"JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4\",\"account\":{\"executable\":true,\"owner\":\"BPFLoaderUpgradeab1e11111111111111111111111\",\"loader\":\"BPFLoaderUpgradeab1e11111111111111111111111\",\"dataSize\":36,\"lamports\":1141440,\"sol\":0.00114144,\"upgradeable\":true,\"upgradeAuthority\":\"6m2...\",\"immutable\":false,\"frozen\":false,\"lastDeploySlot\":271828182,\"lastDeployTs\":1719360000},\"label\":\"Jupiter Aggregator v6\",\"activity\":{\"sampled\":1000,\"txPerMin\":842.31,\"failPct\":4.2,\"lastActiveTs\":1719446400,\"lowActivity\":false},\"typeMix\":[{\"type\":\"SWAP\",\"count\":78,\"pct\":78.0},{\"type\":\"UNKNOWN\",\"count\":22,\"pct\":22.0}],\"recentTxs\":[{\"type\":\"SWAP\",\"description\":\"Swapped 1 SOL for 162 USDC\",\"feePayer\":\"5Q5...\",\"signature\":\"3xQ...\",\"ts\":1719446400,\"error\":null}]}",
     "responseNotes": "account{} — executable, owner, loader, dataSize (bytes), lamports/sol, upgradeable, upgradeAuthority, immutable, frozen (=immutable===true), lastDeploySlot, lastDeployTs. label from the known-program table then the static service registry (null if unknown). activity{} sampled from up to 1000 recent signatures: sampled count, txPerMin (over the sampled span, null if no span), failPct (sampled), lastActiveTs (epoch sec), lowActivity (sampled < 50). typeMix[] = enhanced-tx type histogram (over up to 100 txs) sorted by count, with pct. recentTxs[] = up to 25 decoded txs {type, description, feePayer, signature, ts, error}. Cached 120s.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid program id (not base58)"
      },
      {
       "code": 503,
       "when": "program provider (Stryke's node layer) not configured"
      },
      {
       "code": 502,
       "when": "Program scan unavailable (RPC/enhanced-tx upstream failed) — error \"program scan unavailable\""
      }
     ],
     "id": "programs-get"
    }
   ]
  },
  {
   "label": "Positions",
   "groupSummary": "DeFi position aggregation across Kamino, Drift, MarginFi, Jupiter Perps.",
   "endpoints": [
    {
     "method": "GET",
     "path": "/api/v2/positions/{wallet}",
     "name": "DeFi positions",
     "summary": "Aggregated DeFi positions across Kamino (lend/borrow), Drift (perp + spot), MarginFi (lend/borrow) and Jupiter Perps, flattened into one normalized position contract. Each protocol is fetched independently and isolated — one failing leg never sinks the others.",
     "authRequired": true,
     "pathParams": [
      {
       "name": "wallet",
       "type": "string",
       "description": "Solana wallet address (base58)."
      }
     ],
     "responseExample": "{\"success\":true,\"wallet\":\"5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1\",\"count\":3,\"positions\":[{\"protocol\":\"kamino\",\"type\":\"lend\",\"market\":\"Main: SOL\",\"sizeUsd\":12500.0,\"pnlUsd\":null,\"leverage\":null,\"healthFactor\":1.84},{\"protocol\":\"drift\",\"type\":\"perp\",\"market\":\"SOL-PERP\",\"sizeUsd\":3200.0,\"pnlUsd\":-145.22,\"leverage\":null,\"healthFactor\":null},{\"protocol\":\"jupiter_perps\",\"type\":\"perp\",\"market\":\"SOL-PERP\",\"sizeUsd\":5000.0,\"pnlUsd\":312.5,\"leverage\":4.2,\"healthFactor\":null}]}",
     "responseNotes": "positions[] of {protocol ('kamino'|'drift'|'marginfi'|'jupiter_perps'), type ('lend'|'borrow'|'perp'), market (human label), sizeUsd (always ≥0, glitch-capped 1e12), pnlUsd (signed; null where the protocol decode can't derive it — Kamino, MarginFi, Drift spot), leverage (number or null; only Jupiter Perps exposes it), healthFactor (Kamino obligation-level only, else null)}. Kamino USD is i128/2^60 fixed-point; Drift/MarginFi are on-chain borsh decodes; Jupiter Perps is public REST. Empty/dust legs are dropped. Cached 300s.",
     "errorCodes": [
      {
       "code": 400,
       "when": "invalid wallet address"
      },
      {
       "code": 502,
       "when": "Unexpected aggregation failure (individual protocol failures are swallowed and return no rows, not a 502)"
      }
     ],
     "id": "positions-get"
    }
   ]
  }
 ]
}