openapi: 3.1.0
info:
  title: OnChainRisk API
  description: |
    **[⬇ Download OpenAPI spec (YAML)](/openapi.yaml)** — for codegen tools (openapi-generator, orval, etc.)

    ---

    Blockchain forensics and wallet risk analysis API. Analyze addresses, trace transactions, detect honeypot tokens, and investigate blockchain activity.

    `POST /api/v1/check` accepts **23 networks** at validation. Acceptance is not a coverage
    guarantee: per-network coverage stage and historical window vary by chain and are published
    in the live coverage matrix at https://app.onchainrisk.io/coverage. Endpoint-specific network
    support is narrower for some endpoints (for example deep analysis and token check) and is
    enforced with a structured `422` at the request boundary.

    ## Authentication

    Authenticate with an API key. Create keys in [API Keys](https://app.onchainrisk.io/dashboard/api-keys) (requires Pro plan or above).

    Send your key in the `Authorization` header:
    ```
    Authorization: Bearer ocr_live_...
    ```

    ## Rate Limits

    | Plan | Checks/month | AI Investigations/month | API Access |
    |------|-------------|------------------------|------------|
    | Free | 10/day | 3 | No |
    | Pro | 1,000 | 50 | Yes (10 req/s) |
    | Business | 5,000 | 200 | Yes (50 req/s) |
    | Enterprise | Unlimited | Unlimited | Yes (custom) |

    Cost per request:
    - `POST /api/v1/check` — 1 credit
    - `POST /api/v1/check/deep` — 1 credit
    - `POST /api/v1/token/check` — 1 credit (charged on save via `POST /api/v1/token/reports` when `honeypot.status === "ok"`; non-ok statuses are not charged)
    - `POST /api/v1/block/analyze` — 1 credit (auto-saves report)
    - `POST /api/v1/multichain/scan` — **5 credits** (auto-saves report)
    - `POST /api/v1/multichain/analyze` — **5 credits** (background full analysis; charged separately from `/scan`)

    Client-side idempotency is not currently supported. Repeated or
    concurrent successful requests may be billed separately. Avoid automatic
    retries unless the previous request failed before receiving a response.

    Rate limit headers are included in check responses:
    - `X-RateLimit-Limit` — Monthly limit
    - `X-RateLimit-Remaining` — Remaining checks
    - `X-Credits-Remaining` — Purchased credits remaining

    ## Validation errors

    Address validation runs before any blockchain scan. On failure the API
    returns **HTTP 422** (not 400) with the following stable envelope:

    ```json
    {
      "success": false,
      "error": {
        "message": "Validation failed",
        "code": "VALIDATION_ERROR",
        "details": {
          "address": "Address format does not match selected network. The address looks like a tron address, but network was set to eth.",
          "errorCode": "ADDRESS_NETWORK_MISMATCH",
          "detectedFamily": "tron",
          "requestedNetwork": "eth"
        }
      }
    }
    ```

    `error.code` is always `VALIDATION_ERROR` for this class. The granular
    reason lives in `error.details.errorCode`:

    | `errorCode`                   | When                                                                             |
    |-------------------------------|----------------------------------------------------------------------------------|
    | `INVALID_ADDRESS`             | empty, wrong format, whitespace-only                                             |
    | `UNSUPPORTED_NETWORK`         | `network` not in the supported list                                              |
    | `COULD_NOT_DETECT_NETWORK`    | address format unrecognised AND `network` omitted                                |
    | `INVALID_ADDRESS_CHECKSUM`    | decode succeeded, family checksum failed (Tron / BTC-legacy / LTC-legacy). Solana decode failures surface as `INVALID_ADDRESS` because Solana is not checksum-based. |
    | `ADDRESS_NETWORK_MISMATCH`    | detected address family inconsistent with requested `network`                    |

    `detectedFamily` and `requestedNetwork` are present only when they add
    useful diagnostic context; both may be `null` or absent.

    ## Supported Networks (23)

    | Network | Code | Type |
    |---------|------|------|
    | Ethereum | `eth` | Layer 1 |
    | BNB Smart Chain | `bsc` | Layer 1 |
    | Tron | `tron` | Layer 1 |
    | Bitcoin | `btc` | Layer 1 |
    | Solana | `sol` | Layer 1 |
    | TON | `ton` | Layer 1 |
    | Litecoin | `ltc` | Layer 1 |
    | Arbitrum | `arbitrum` | Layer 2 |
    | Optimism | `optimism` | Layer 2 |
    | Base | `base` | Layer 2 |
    | Polygon | `polygon` | Layer 2 |
    | Avalanche | `avalanche` | Layer 1 |
    | Linea | `linea` | Layer 2 |
    | zkSync Era | `zksync` | Layer 2 |
    | Scroll | `scroll` | Layer 2 |
    | Celo | `celo` | Layer 1 |
    | Cronos | `cronos` | Layer 1 |
    | Fantom | `fantom` | Layer 1 |
    | Moonbeam | `moonbeam` | Parachain |
    | Polygon zkEVM | `polygon_zkevm` | Layer 2 |
    | Cosmos | `cosmos` | Layer 1 |
    | Cardano | `cardano` | Layer 1 |
    | XRP | `xrp` | Layer 1 |

    ## Credits

    When monthly quota is exhausted, purchased credits are used automatically.
    - Check credits: 1 credit = 1 address/token/block analysis
    - AI credits: 1 credit = 1 AI investigation (separate pool)
    - Multi-chain full analysis: 5 check credits
    - Graph expansion: FREE (unlimited)

  version: 2.0.0
  contact:
    name: OnChainRisk Support
    url: https://app.onchainrisk.io/dashboard/support
    email: admin@onchainrisk.io

servers:
  - url: https://api.onchainrisk.io
    description: Production API

tags:
  - name: Analysis
    description: Address risk analysis, token check, block analysis
  - name: Multi-Chain
    description: Multi-chain wallet scan and full analysis
  - name: Sharing
    description: Public share tokens for reports
  - name: Saved Reports
    description: Token / block / multichain report persistence
  - name: AI Investigation
    description: AI-powered blockchain investigation agent
  - name: Watchlist
    description: Address monitoring and alerts
  - name: Graph
    description: Counterparty expansion and CSV import
  - name: Labels
    description: Custom address labels + bulk import/export
  - name: Notifications
    description: Notification channel preferences (email, Telegram, webhook)
  - name: Account
    description: User profile, usage, settings, branding, layout

paths:
  # ── Analysis ──────────────────────────────────────────
  /api/v1/check:
    post:
      tags: [Analysis]
      summary: Analyze address risk
      description: |
        Submit an address for full risk analysis. Returns risk score (0-100), pattern flags, counterparties, timing analysis, cross-chain activity, and more. Network is auto-detected from address format, or specify explicitly. Costs 1 check credit.

        **Sync vs async path.** Most addresses return a fully analysed report
        in a single 200 response. Heavy addresses (very high tx count, large
        smart contracts) take the async path: the API returns 202 with a
        `reportId` and the consumer worker fills in the analysis 1–5 minutes
        later. Poll `GET /api/v1/check/status/{reportId}` until `status` flips
        from `queued` / `analyzing` to `complete` (or `failed`). Once
        `complete`, fetch the full report with `GET /api/reports/{reportId}`
        (`reportId` is the value returned here).

        **Idempotent retries (30-min window).** If you POST again for the
        same `(api-key, address, network)` while a previous scan is still
        in flight, the API returns the existing in-flight `reportId` with
        `deduplicated: true`. No new charge, no new queue job. Useful for
        UI re-clicks and client-side retries.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address:
                  type: string
                  description: Wallet or contract address
                  example: "0x742d35CC6634C0532925a3b844bc454e4438F44e"
                network:
                  type: string
                  description: Network code (auto-detected if omitted)
                  enum: [eth, bsc, tron, btc, ltc, sol, ton, arbitrum, optimism, base, polygon, avalanche, linea, zksync, scroll, celo, cronos, fantom, moonbeam, polygon_zkevm, cosmos, cardano, xrp]
                  example: eth
      responses:
        '200':
          description: Analysis result (sync path)
          headers:
            X-RateLimit-Limit:
              schema: { type: integer }
            X-RateLimit-Remaining:
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalysisResult'
        '202':
          description: |
            Async path — analysis queued. Poll the report endpoint for completion.
            Two variants:
              * **queued** — first time this address was submitted in the last 30 minutes.
              * **deduplicated** — the same (key, address, network) is already in flight; the existing reportId is returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AsyncQueuedResponse'
              examples:
                queued:
                  summary: First submission, queued for analysis
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: queued
                      async: true
                deduplicated:
                  summary: Idempotent retry within 30 minutes
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: queued
                      async: true
                      deduplicated: true
        '422':
          $ref: '#/components/responses/ValidationFailed'
        '429':
          description: Rate limit exceeded
        '503':
          description: Analysis temporarily unavailable

  /api/v1/check/{address}:
    get:
      tags: [Analysis]
      summary: Fetch cached analysis (GET)
      description: |
        Returns a cached report for the address if one exists. Does not consume your quota.
        Add `?network=eth` to select the network; omit it and the address family is
        auto-detected. This endpoint is cache-only and never triggers a fresh analysis —
        to analyse an address now, call `POST /api/v1/check`.

        **Smart contracts:** when the address is a deployed contract, the response includes
        `contractAnalysis` (bytecode metadata) and may include `contractInfo` (proxy/deployment
        metadata). For very high-activity contracts (`addressInfo.txCount >= 50000`) the
        analyzer returns overview-only metadata and `dataCompleteness.partialReason` is set
        to `heavy_contract_overview`.
      security:
        - apiKeyAuth: []
      parameters:
        - name: address
          in: path
          required: true
          schema: { type: string }
        - name: network
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Analysis result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalysisResult'
              examples:
                verifiedContract:
                  summary: Normal verified smart contract
                  description: |
                    Standard response for a verified contract with low transaction count.
                    `contractAnalysis` carries full bytecode metadata; `dataCompleteness` is
                    not partial.
                  value:
                    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
                    network: eth
                    riskScore: 5
                    riskLevel: low
                    addressInfo:
                      type: smart_contract
                      balance: "0 ETH"
                      txCount: 412
                      firstSeen: "2020-06-09T18:43:35Z"
                      lastSeen: "2026-04-25T10:11:02Z"
                    patternFlags: []
                    contractAnalysis:
                      contractName: "FiatTokenV2_2"
                      bytecodeSize: 24576
                      compilerVersion: "v0.6.12+commit.27d51765"
                      deployer: "0x95Ba4cF87D6723ad9C0Db21737D862bE80e93911"
                      isVerified: true
                      hasSelfdestruct: false
                      hasDelegatecall: true
                      hasCreate2: false
                      similarityFlags: ["ERC20", "Upgradeable"]
                      knownSignatures: ["transfer(address,uint256)", "approve(address,uint256)", "balanceOf(address)"]
                    contractInfo:
                      deployer: "0x95Ba4cF87D6723ad9C0Db21737D862bE80e93911"
                      creationTx: "0xe7e0fe390354509cd08c9a0168536938600ddc552b3f7cb96030ebef62e75895"
                      creationBlock: 6082465
                      isProxy: true
                      implementationAddress: "0xa2327a938Febf5FEC13baCFb16Ae10EcBc4cbDCF"
                      contractName: "FiatTokenV2_2"
                    dataCompleteness:
                      reportedTxCount: 412
                      fetchedTxCount: 412
                      completenessPct: 100
                      partialReason: null
                    analysisTime: 4321
                    timestamp: "2026-04-25T10:15:00Z"
                heavyContractOverview:
                  summary: Heavy contract — overview only
                  description: |
                    Response when `addressInfo.txCount >= 50000` and the analyzer returned a
                    minimal `contractAnalysis` shape. `dataCompleteness.partialReason` is
                    `heavy_contract_overview`. `similarityFlags` and `knownSignatures` are
                    still arrays (possibly empty) — the backend normalizes the shape so the
                    consumer never has to runtime-guard.
                  value:
                    address: "0x1F98431c8aD98523631AE4a59f267346ea31F984"
                    network: eth
                    riskScore: null
                    riskLevel: low
                    addressInfo:
                      type: contract
                      balance: "0 ETH"
                      txCount: 267575
                      firstSeen: "2021-05-04T23:10:00Z"
                      lastSeen: "2026-04-25T09:55:21Z"
                    patternFlags: []
                    contractAnalysis:
                      contractName: null
                      bytecodeSize: 6177
                      compilerVersion: "v0.8.16+commit.07a7930e"
                      deployer: null
                      isVerified: true
                      hasSelfdestruct: false
                      hasDelegatecall: false
                      hasCreate2: false
                      similarityFlags: []
                      knownSignatures: []
                    dataCompleteness:
                      reportedTxCount: 267575
                      fetchedTxCount: 0
                      completenessPct: 0
                      partialReason: heavy_contract_overview
                    partialReason: heavy_contract_overview
                    analysisTime: 1820
                    timestamp: "2026-04-25T10:00:00Z"
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /api/v1/check/status/{reportId}:
    get:
      tags: [Analysis]
      summary: Async scan status (polling)
      description: |
        Poll the status of an async scan that POST `/api/v1/check` returned with
        `async: true`. Lightweight: returns lifecycle status + risk summary
        once complete — **this endpoint never returns the full report body.**
        Does NOT consume quota. Authenticated with the same API key that
        initiated the scan.

        When `status` is `complete`, retrieve the full analysis with
        `GET /api/reports/{reportId}`.

        **Lifecycle:**
          `queued` → `analyzing` → `complete` | `failed`

        Poll every 5 seconds until status is `complete` or `failed`. Typical
        end-to-end completion is 1–5 minutes for heavy addresses.
      security:
        - apiKeyAuth: []
      parameters:
        - name: reportId
          in: path
          required: true
          description: The reportId returned by POST /api/v1/check (UUID).
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current status and (when complete) risk summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanStatus'
              examples:
                queued:
                  summary: Just queued
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: queued
                      phase: null
                      errorMessage: null
                      riskScore: null
                      riskLevel: null
                      queuedAt: "2026-04-26T22:27:30Z"
                      startedAt: null
                      finishedAt: null
                analyzing:
                  summary: Consumer working on it
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: analyzing
                      phase: fetching_transactions
                      errorMessage: null
                      riskScore: null
                      riskLevel: null
                      queuedAt: "2026-04-26T22:27:30Z"
                      startedAt: "2026-04-26T22:27:35Z"
                      finishedAt: null
                complete:
                  summary: Done — fetch the full report with GET /api/reports/{reportId}
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: complete
                      phase: null
                      errorMessage: null
                      riskScore: 5
                      riskLevel: low
                      queuedAt: "2026-04-26T22:27:30Z"
                      startedAt: "2026-04-26T22:27:35Z"
                      finishedAt: "2026-04-26T22:30:12Z"
                failed:
                  summary: Scan failed — error available
                  value:
                    success: true
                    data:
                      reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                      status: failed
                      phase: null
                      errorMessage: "Upstream analysis timeout after 5 minutes"
                      riskScore: null
                      riskLevel: null
                      queuedAt: "2026-04-26T22:27:30Z"
                      startedAt: "2026-04-26T22:27:35Z"
                      finishedAt: "2026-04-26T22:32:35Z"
        '404':
          description: Report not found or not owned by this API key
        '401':
          description: Unauthorized

  /api/reports/{id}:
    get:
      tags: [Analysis]
      summary: Fetch a saved report by id
      description: |
        Retrieve one of **your own** reports by its `reportId` — the stable
        UUID returned by POST `/api/v1/check` (a legacy integer id also works).
        This is the canonical way to fetch the full analysis after an async
        scan completes: `GET /api/v1/check/status/{reportId}` returns only the
        lifecycle summary, never the report body.

        Accepts the same API key that created the report. Returns 404 if the id
        does not exist or is not owned by the caller. Does NOT consume quota.

        **Response shape:**
          * complete report → full `AnalysisResult`.
          * async report still in flight (`queued` / `analyzing`) → a
            lightweight body carrying `reportId`, `address`, `network`,
            `status` and `createdAt` (no analysis fields yet — keep polling
            `GET /api/v1/check/status/{reportId}`).
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report id — UUID `public_id` (preferred) or legacy integer id.
          schema: { type: string }
      responses:
        '200':
          description: Full report (complete) or lightweight status body (in-flight)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AnalysisResult'
                  - type: object
                    description: In-flight async report — analysis not yet written.
                    required: [reportId, status]
                    properties:
                      reportId: { type: string }
                      address: { type: string }
                      network: { type: string }
                      status: { $ref: '#/components/schemas/ReportStatus' }
                      errorMessage: { type: string, nullable: true }
                      createdAt: { type: string, format: date-time }
              examples:
                complete:
                  summary: Completed report — full analysis body
                  value:
                    reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                    address: "0x742d35CC6634C0532925a3b844bc454e4438F44e"
                    network: eth
                    riskScore: 5
                    riskLevel: low
                    createdAt: "2026-04-26T22:30:12Z"
                inFlight:
                  summary: Async report still being analysed
                  value:
                    reportId: "98958045-d736-4bc4-90ed-a717a9258738"
                    address: "0x742d35CC6634C0532925a3b844bc454e4438F44e"
                    network: eth
                    status: analyzing
                    createdAt: "2026-04-26T22:27:30Z"
        '401':
          description: Unauthorized
        '404':
          description: Report not found or not owned by the caller

  /api/v1/check/deep:
    post:
      tags: [Analysis]
      summary: Deep analysis (peel chain detection)
      description: |
        Trace transaction origin through intermediary wallets. Detects
        peel chains and fund splitting. Costs 1 check credit.

        **Saved custom risk settings do not apply here.** Custom weights
        (`custom_weights`), custom override floors (`custom_overrides`),
        and custom labels (`custom_labels`) are not forwarded to the
        deep-analysis pipeline because the response carries `peelChain`
        only — no `riskScore` / `riskLevel` / `riskReasons`. To get a
        scored result that honors your saved settings, call
        `/api/v1/check` instead. (F-H — #52.2 docs hardening.)
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string }
                network:
                  type: string
                  enum: [eth, arbitrum, optimism, base, polygon, avalanche, linea, zksync, scroll, celo, cronos, fantom, moonbeam, polygon_zkevm]
                  description: |
                    Networks where deep analysis (peel chain detection)
                    is production-verified. Networks accepted by
                    `/api/v1/check` validation but not in this set
                    (`bsc`, `btc`, `ltc`, `sol`, `tron`, `ton`,
                    `cosmos`, `cardano`, `xrp`) return `422
                    DEEP_ANALYSIS_UNSUPPORTED_NETWORK` at the Worker
                    boundary before any upstream analysis call —
                    matching the `deep-analysis-unsupported-network`
                    example below. No credits are charged on this path.
                reportId: { type: string, description: "Attach result to existing report" }
      responses:
        '200':
          description: Peel chain result
          content:
            application/json:
              schema:
                type: object
                properties:
                  peelChain:
                    type: array
                    items:
                      type: object
                      properties:
                        address: { type: string }
                        label: { type: string, nullable: true }
                        riskLevel: { type: string }
                        depth: { type: integer }
        '422':
          $ref: '#/components/responses/ValidationFailed'
          # Includes deep-analysis-unsupported-network example
          # (`error.details.errorCode = DEEP_ANALYSIS_UNSUPPORTED_NETWORK`)
          # for permanent network gates. Pre-2026-05-15 these returned
          # 503 DEEP_ANALYSIS_UNAVAILABLE — fixed in Task #56.
        '503':
          description: |
            Transient upstream failure (timeout, Modal 5xx, network
            connectivity). Retry with backoff is appropriate. Permanent
            unsupported-network errors are 422, not 503 — see the
            `deep-analysis-unsupported-network` example above.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:
                    type: object
                    properties:
                      message: { type: string }
                      code: { type: string, example: "DEEP_ANALYSIS_UNAVAILABLE" }

  /api/v1/token/check:
    post:
      tags: [Analysis]
      summary: Token security check (honeypot detection)
      description: |
        Analyze an ERC-20 token contract for honeypot risk, sell tax, blacklist functions, ownership concentration, and liquidity. Simulates buy/sell transactions. Costs 1 check credit.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string, description: "Token contract address" }
                network:
                  type: string
                  enum: [eth, bsc, arbitrum, optimism, base, polygon, avalanche, linea, zksync, tron]
                  description: |
                    Networks where token analysis is production-verified.
                    Other networks (scroll, celo, cronos, fantom, moonbeam,
                    polygon_zkevm, and remaining non-EVM chains) return 422
                    NETWORK_NOT_SUPPORTED_FOR_TOKEN_CHECK.

                    Tron exception (F-002b-2, 2026-05-18): Tron is supported
                    as a *honeypot-only* surface. The response carries the
                    full `honeypot` object with the `honeypot.status` enum
                    contract (see schema), but `tokenScore`, `topHolders`,
                    and `tokenMeta` are intentionally `null` for `tron` —
                    those sections require EVM JSON-RPC, which is not
                    available on tron. Billing/save semantics are unchanged:
                    `status='ok'` saves charge 1 credit; non-ok statuses
                    return `charged: false` and no row is created.
      responses:
        '200':
          description: Token security analysis
          content:
            application/json:
              schema:
                type: object
                properties:
                  sections:
                    type: object
                    description: "Security checks: ownership, mint, fee, blacklist, proxy, liquidity, holders"
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /api/v1/token/check/stream:
    post:
      tags: [Analysis]
      summary: Token check (SSE streaming)
      description: Same as `/api/v1/token/check` but returns results progressively via Server-Sent Events as each section completes.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string }
                network: { type: string }
      responses:
        '200':
          description: SSE stream of section results
          content:
            text/event-stream: {}
        '422':
          $ref: '#/components/responses/ValidationFailed'

  /api/v1/block/analyze:
    post:
      tags: [Analysis]
      summary: Block MEV analysis
      description: |
        Analyze all transactions in a block for MEV patterns: sandwich attacks, arbitrage, flash loans, JIT liquidity, and liquidations. **Costs 1 credit.** Auto-saves the block report; the response includes `reportPublicId` for retrieval via `GET /api/v1/block/reports/{id}`.

        Supported networks: `eth`, `arbitrum`, `optimism`, `avalanche`, `scroll`, `linea`, `zksync`, `bsc`, `polygon`, `base` — all 10 EVM block-analyze networks. Future block (block number greater than current chain tip) returns 404 `BLOCK_NOT_FOUND`. Generic upstream failures return 503 `BLOCK_ANALYZE_UNAVAILABLE`.

        Client-side idempotency is not currently supported. Repeated or concurrent successful requests may be billed separately.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blockNumber, network]
              properties:
                blockNumber: { type: integer, minimum: 0 }
                network:
                  type: string
                  enum: [eth, arbitrum, optimism, avalanche, scroll, linea, zksync, bsc, polygon, base]
                  description: |
                    All 10 EVM block-analyze networks are supported.
                    `bsc` and `polygon` were re-enabled 2026-05-05 (Phase 2b)
                    after the PoA / extended-extraData middleware fix.
                    `base` was re-enabled 2026-05-05 (Phase 2c) after the
                    Modal BASE_RPC_URL secret was rotated to the official
                    public Base RPC.
      responses:
        '200':
          description: Block analysis with MEV detection (auto-saved as a block report)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reportId: { type: string, description: 'Stable UUID for the auto-saved block report. Same value as `reportPublicId`.' }
                  reportPublicId: { type: string, description: 'Stable UUID for the auto-saved block report.' }
                  cached: { type: boolean, description: 'True when the response was served from a 24h cache; the report still exists and is linked here.' }
                  blockNumber: { type: integer }
                  timestamp: { type: integer }
                  sandwiches: { type: array, items: { type: object } }
                  arbitrages: { type: array, items: { type: object } }

  # /api/v1/check/status/{id} — superseded by /api/v1/check/status/{reportId}
  # above (PR #48 / #48.1). Single canonical path; removed the stale
  # duplicate that used `processing` instead of `analyzing` in the enum.

  # Internal endpoints intentionally NOT documented here:
  # - /api/analyze/estimate — internal preflight; not exposed at
  #   api.onchainrisk.io (direct external calls 404).
  # - /api/v1/telemetry/archetype — dashboard client telemetry for AI archetype
  #   learning loop; not part of the public integration API.
  # - /api/admin/* — internal surface
  # - /api/notifications/telegram/callback — Telegram server-to-server webhook
  #
  # Removed in #729 after a route-by-route auth audit. Each had been published under
  # `apiKeyAuth` while its runtime handler rejects API keys, so the documented call could
  # never have worked:
  # - /api/v1/mev/bots, /api/v1/mev/stats, /api/v1/mev/scan/trigger — every /api/v1/mev/*
  #   handler calls requireAdmin(); the manual trigger is additionally frozen and returns
  #   403 MEV_SCAN_DISABLED even for an authorized admin. /api/v1/mev/pool/{address} is
  #   admin-only too and was never published. MEV remains an internal/admin feature.
  # - /api/keys, /api/keys/{id} — listApiKeys/createApiKey/revokeApiKey call
  #   requireSessionAuth(): an API key cannot mint or revoke API keys. Key management is
  #   dashboard-only by design.
  #
  # Removed in the #729 remediation pass. These resolve to real routes that a paid key can
  # call, but they are first-party dashboard workflows, not third-party integration APIs:
  # - /api/support/tickets (POST, GET) — the dashboard is the consumer. The published pair
  #   could not support an integration anyway: the conversation flow it depends on
  #   (ticket detail, message thread, unread state) is session-only and unpublished, and
  #   the published request/response contract had drifted from runtime in five ways.
  # - /api/user/me, /api/user/usage — account-management surface per docs/INTERNAL_API.md.
  #   GET /api/v1/usage is the supported public replacement for quota introspection and
  #   accepts sandbox keys; it is published in both the paid and sandbox contracts.
  # `npm run validate:public-api-auth` derives the auth invariant from runtime source, and
  # `npm run validate:public-contract` additionally requires every published operation to
  # carry a reviewed PUBLIC/SANDBOX intent in scripts/public-api-authority.mjs — so a route
  # cannot re-enter this document just because `requireAuth` accepts an API key.

  # ── Multi-Chain ───────────────────────────────────────
  /api/v1/multichain/scan:
    post:
      tags: [Multi-Chain]
      summary: Quick multi-chain scan
      description: |
        Scan one address across 15 EVM networks in parallel. Returns balances, transaction counts, and active chains. **Costs 5 credits.** Auto-saves the multi-chain report; the response includes `reportPublicId` for retrieval via `GET /api/v1/multichain/reports/{id}`.

        Client-side idempotency is not currently supported. Repeated or concurrent successful requests may be billed separately.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string, description: "EVM address (0x...)" }
      responses:
        '200':
          description: Multi-chain scan result (auto-saved as a multi-chain report)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reportId: { type: string, description: 'Stable UUID for the auto-saved multi-chain report. Same value as `reportPublicId`.' }
                  reportPublicId: { type: string, description: 'Stable UUID for the auto-saved multi-chain report.' }
                  cached: { type: boolean, description: 'True when served from the 5-min user-scoped cache; the linked report still exists.' }
                  address: { type: string }
                  activeChains: { type: integer }
                  totalChains: { type: integer }
                  chains: { type: array, items: { type: object } }
        '410':
          description: Cached scan exists but the original report was deleted, or legacy cache without a report link. Re-run the scan to create a fresh report.
        '429':
          description: Insufficient credits to spend the required 5 credits. Quota gate runs before any external scan, so no charge has occurred.

  /api/v1/multichain/analyze:
    post:
      tags: [Multi-Chain]
      summary: Full multi-chain risk analysis
      description: |
        Run full risk analysis on each active network. Background job with progress polling. Returns composite risk score and per-network breakdown. Costs 5 check credits.

        **Saved custom risk settings apply.** Since 2026-05-20 (PR #76),
        per-network and composite scores honor the same saved
        `custom_weights`, `custom_overrides`, and `custom_labels` that
        `/api/v1/check` already applies. Customs are loaded server-side
        from your `PATCH /api/user/risk-settings` state; the request body
        from the dashboard / API client stays `{ address, networks }`.
        `/api/v1/multichain/scan` is balance / holdings only — no risk
        scoring, so customs have nowhere to land there.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, networks]
              properties:
                address: { type: string }
                networks: { type: array, items: { type: string } }
      responses:
        '202':
          description: Analysis started
          content:
            application/json:
              schema:
                type: object
                properties:
                  reportId: { type: integer }
                  status: { type: string, enum: [analyzing] }
                  cost: { type: integer, example: 5 }
        '422':
          $ref: '#/components/responses/ValidationFailed'
          # Malformed JSON, null/non-object body, invalid address, or
          # empty/missing networks all return 422 VALIDATION_ERROR with
          # an explicit `error.details.body | address | networks` message.

  /api/v1/multichain/analyze/{reportId}:
    get:
      tags: [Multi-Chain]
      summary: Poll analysis progress
      security:
        - apiKeyAuth: []
      parameters:
        - name: reportId
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Progress status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [analyzing, complete, failed] }
                  networks: { type: object }
                  compositeRiskScore: { type: integer }

  # ── Usage / quota introspection ───────────────────────
  /api/v1/usage:
    get:
      tags: [Account]
      summary: Get current API usage and quota
      description: |
        Read-only quota and usage state for the authenticated API key (or
        session). Use this to check remaining checks before scheduling a
        scan, or to surface quota in your own dashboard.

        Authentication accepts paid API keys, sandbox API keys (read-only
        introspection — sandbox cannot use other paid /api/v1/* compute
        endpoints), and browser session JWT.

        Quota semantics:
          - `plan_quota` is the recurring plan-included check quota.
            Paid plans (pro, business, enterprise) have a monthly window.
            Free has a rolling 24-hour window.
          - `purchased_credits.available` is the standalone credit balance,
            independent of `plan_quota.used`. Credits are consumed only
            after `plan_quota` is exhausted (or via `?source=credits`).
          - `rate_limit.requests_per_second` is throughput (per-plan tier),
            independent of `plan_quota`.
          - Enterprise plans return `plan_quota.limit = null` and
            `plan_quota.remaining = null` (unlimited). The internal
            sentinel value 999999 is never exposed.
      security:
        - apiKeyAuth: []
      responses:
        '200':
          description: Current usage and quota
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    required: [plan, key_type, billing_period, plan_quota, purchased_credits, rate_limit]
                    properties:
                      plan:
                        type: string
                        enum: [free, pro, business, enterprise]
                        example: pro
                      key_type:
                        type: string
                        enum: [paid, sandbox]
                        nullable: true
                        description: Type of API key. `null` when authenticated via session JWT.
                        example: paid
                      billing_period:
                        type: object
                        required: [type, start, end]
                        properties:
                          type:
                            type: string
                            enum: [monthly, rolling_24h, none]
                            example: monthly
                          start:
                            type: string
                            format: date-time
                            nullable: true
                            description: ISO-8601 UTC. Null for `rolling_24h` and `none`.
                          end:
                            type: string
                            format: date-time
                            nullable: true
                            description: ISO-8601 UTC. Null for `rolling_24h` and `none`.
                      plan_quota:
                        type: object
                        required: [used, limit, remaining]
                        properties:
                          used:
                            type: integer
                            description: Plan quota checks consumed in current window.
                            example: 12
                          limit:
                            type: integer
                            nullable: true
                            description: Plan quota cap. `null` for enterprise (unlimited).
                            example: 1000
                          remaining:
                            type: integer
                            nullable: true
                            description: max(0, limit - used). `null` when limit is null.
                            example: 988
                      purchased_credits:
                        type: object
                        required: [available]
                        properties:
                          available:
                            type: integer
                            description: Standalone purchased-credit balance, separate from plan_quota.
                            example: 50
                      rate_limit:
                        type: object
                        required: [requests_per_second]
                        properties:
                          requests_per_second:
                            type: integer
                            description: Per-plan throughput cap.
                            example: 10
              examples:
                pro:
                  summary: Pro plan with 12 checks used and 50 purchased credits
                  value:
                    success: true
                    data:
                      plan: pro
                      key_type: paid
                      billing_period:
                        type: monthly
                        start: "2026-04-01T00:00:00.000Z"
                        end:   "2026-05-01T00:00:00.000Z"
                      plan_quota:
                        used: 12
                        limit: 1000
                        remaining: 988
                      purchased_credits:
                        available: 50
                      rate_limit:
                        requests_per_second: 10
                free:
                  summary: Free plan (rolling 24h window)
                  value:
                    success: true
                    data:
                      plan: free
                      key_type: null
                      billing_period:
                        type: rolling_24h
                        start: null
                        end:   null
                      plan_quota:
                        used: 3
                        limit: 10
                        remaining: 7
                      purchased_credits:
                        available: 0
                      rate_limit:
                        requests_per_second: 1
                enterprise:
                  summary: Enterprise plan (unlimited — no internal sentinel exposed)
                  value:
                    success: true
                    data:
                      plan: enterprise
                      key_type: paid
                      billing_period:
                        type: monthly
                        start: "2026-04-01T00:00:00.000Z"
                        end:   "2026-05-01T00:00:00.000Z"
                      plan_quota:
                        used: 4521
                        limit: null
                        remaining: null
                      purchased_credits:
                        available: 0
                      rate_limit:
                        requests_per_second: 100
        '401':
          description: Authentication required or invalid API key

  /api/v1/webhook/deliveries:
    get:
      tags: [Account]
      summary: List webhook delivery audit (#41 Phase 1)
      description: |
        Per-user webhook delivery audit, newest first. One row per
        `event_id`; status reflects current state across retries.

        Use this to debug "did my webhook actually arrive" — the row
        carries the result of every attempt path: 2xx delivered, 5xx/429
        scheduled retry, 4xx terminal failure, 410 disabled, network/TLS
        timeout retry.

        Privacy: response carries `endpoint_host` (hostname) only. Full
        webhook URL, secret, and request payload bytes are NEVER returned.
        Configure your webhook URL via `PATCH /api/notifications/settings`.

        Pagination: cursor-based on `id` (descending). When `next_cursor`
        is non-null, pass it back as `?cursor=<value>` for the next page.

        Authentication accepts paid API keys, sandbox API keys (read-only
        introspection), and browser session JWT.
      security:
        - apiKeyAuth: []
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [delivering, delivered, retry_scheduled, failed, disabled]
          description: Filter to a single delivery status.
        - in: query
          name: limit
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
          description: Page size (default 20, max 100).
        - in: query
          name: cursor
          schema:
            type: string
          description: Pagination cursor returned by the previous page (`next_cursor`).
      responses:
        '200':
          description: Audit page
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    required: [deliveries, next_cursor]
                    properties:
                      deliveries:
                        type: array
                        items:
                          type: object
                          required: [event_id, event_type, status, attempts_count, endpoint_host, created_at, updated_at]
                          properties:
                            event_id:
                              type: string
                              example: wh_alert_12345_v1
                            event_type:
                              type: string
                              enum: [watchlist_alert, test]
                              example: watchlist_alert
                            alert_id:
                              type: integer
                              nullable: true
                              description: Linked watchlist_alert id; null for test deliveries.
                            endpoint_host:
                              type: string
                              description: Hostname of the configured receiver. Full URL never returned.
                              example: receiver.example.com
                            status:
                              type: string
                              enum: [delivering, delivered, retry_scheduled, failed, disabled]
                            attempts_count:
                              type: integer
                              minimum: 0
                              example: 1
                            next_retry_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: ISO-8601 UTC. Non-null only when status=retry_scheduled.
                            last_attempt_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: ISO-8601 UTC of most recent delivery attempt.
                            last_http_status:
                              type: integer
                              nullable: true
                              description: HTTP status from last attempt; null on network-level failure.
                              example: 200
                            last_error_class:
                              type: string
                              nullable: true
                              description: Error category (e.g. http_2xx, http_5xx, http_429, http_4xx_terminal, http_410_gone, AbortError, TypeError). Never carries response body.
                              example: http_2xx
                            delivered_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: ISO-8601 UTC; set once on first 2xx, never cleared.
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      next_cursor:
                        type: string
                        nullable: true
                        description: Pass to `?cursor=` for the next page; null when no more pages.
        '401':
          description: Authentication required or invalid API key
        '422':
          description: Invalid query parameter (e.g. unsupported status)

  # ── AI Investigation ──────────────────────────────────
  /api/investigate:
    post:
      tags: [AI Investigation]
      summary: Start or continue AI investigation
      description: |
        Send a message to the AI investigation agent. Returns SSE stream with analysis steps, tool calls, and results. Costs 1 AI investigation credit (when creating new investigation).
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string, maxLength: 5000 }
                investigationId: { type: integer, description: "Continue existing investigation" }
                reportId: { type: string, description: "Reference existing report" }
                byoKey: { type: string, description: "Bring-your-own Claude API key" }
      responses:
        '200':
          description: SSE stream of investigation events
          content:
            text/event-stream: {}

  /api/investigations:
    get:
      tags: [AI Investigation]
      summary: List investigations
      security:
        - apiKeyAuth: []
      parameters:
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 50 }
        - name: offset
          in: query
          description: Zero-based row offset. This surface pages by offset, not page number.
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: Investigations list (offset-paged)

  # ── Watchlist ─────────────────────────────────────────
  /api/watchlist:
    get:
      tags: [Watchlist]
      summary: List watchlist items
      security:
        - apiKeyAuth: []
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Watchlist items with alert counts
    post:
      tags: [Watchlist]
      summary: Add address to watchlist
      description: |
        Monitor an address for new transactions. Alerts delivered via email, Telegram, or webhook. Checked every 5 minutes.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string }
                network:
                  type: string
                  description: |
                    Only networks the alert monitor can actually poll are accepted. This is
                    narrower than the `/api/v1/check` network set on purpose: enrolment is
                    fail-closed, so a network outside this list is rejected with
                    400 `WATCHLIST_NETWORK_UNSUPPORTED` rather than silently never alerting.
                  enum: [eth, bsc, btc, sol, tron, arbitrum, optimism, base, polygon, avalanche, linea, celo, moonbeam]
                label: { type: string }
                threshold_value: { type: number, description: "Min value to alert" }
                notify_email: { type: boolean, default: true }
                notify_telegram: { type: boolean, default: false }
                notify_webhook: { type: boolean, default: false }
      responses:
        '201':
          description: Watchlist item created
        '400':
          description: |
            Missing address/network, an address that fails family validation, or a network
            outside the monitored set (`WATCHLIST_NETWORK_UNSUPPORTED`).
        '403':
          description: Plan watchlist limit reached

  # ── Labels ────────────────────────────────────────────
  /api/labels:
    get:
      tags: [Labels]
      summary: List user custom labels
      security:
        - apiKeyAuth: []
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 50, maximum: 100 }
        - name: search
          in: query
          description: Case-insensitive substring match on address and label.
          schema: { type: string }
      responses:
        '200':
          description: User labels
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    address: { type: string }
                    network: { type: string }
                    label: { type: string }
                    category: { type: string }
                    notes: { type: [string, 'null'] }
                    createdAt: { type: integer }
    post:
      tags: [Labels]
      summary: Create a custom label
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, label]
              properties:
                address: { type: string }
                network:
                  type: string
                  default: eth
                  description: |
                    Label scope. Narrower than the analysis network set — a network outside
                    this list is rejected with 400. Omit to default to `eth`.
                  enum: [eth, bsc, tron, btc, sol, ton, arbitrum, optimism, base, polygon, avalanche, linea, zksync]
                label: { type: string, maxLength: 200 }
                category:
                  type: string
                  default: custom
                  description: |
                    Free-form tag. Non-alphanumeric characters are stripped and the value is
                    truncated to 50 characters; an empty result becomes `custom`.
                notes:
                  type: string
                  maxLength: 500
                  description: Longer values are truncated to 500 characters, not rejected.
      responses:
        '201':
          description: Label created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }

  /api/labels/{id}:
    patch:
      tags: [Labels]
      summary: Update a label
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Partial update — every field is optional and an omitted field keeps its
                stored value. String fields are truncated to their maximum rather than
                rejected.
              properties:
                label: { type: string, maxLength: 200 }
                category:
                  type: string
                  description: |
                    Non-alphanumeric characters are stripped and the value is truncated to
                    50 characters; an empty result becomes `custom`.
                notes: { type: [string, 'null'], maxLength: 500 }
                network:
                  type: string
                  description: Re-scope the label. Rejected with 400 if outside this list.
                  enum: [eth, bsc, tron, btc, sol, ton, arbitrum, optimism, base, polygon, avalanche, linea, zksync]
      responses:
        '200': { description: Label updated }
        '400': { description: Unsupported network }
        '404': { description: Label not found }
    delete:
      tags: [Labels]
      summary: Delete a label
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200': { description: Label deleted }
        '404': { description: Label not found }

  /api/labels/import:
    post:
      tags: [Labels]
      summary: Bulk import labels
      description: Bulk-create labels. Duplicates (same address+network) are skipped.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [labels]
              properties:
                labels:
                  type: array
                  items:
                    type: object
                    required: [address, network, label, category]
                    properties:
                      address: { type: string }
                      network: { type: string }
                      label: { type: string }
                      category: { type: string }
      responses:
        '200':
          description: Import result
          content:
            application/json:
              schema:
                type: object
                properties:
                  imported: { type: integer }
                  skipped: { type: integer }

  /api/labels/export:
    get:
      tags: [Labels]
      summary: Export all user labels
      security:
        - apiKeyAuth: []
      responses:
        '200':
          description: JSON array of all user labels
          content:
            application/json:
              schema:
                type: array
                items: { type: object }

  # ── Notifications ─────────────────────────────────────
  /api/notifications/settings:
    get:
      tags: [Notifications]
      summary: Get notification settings
      security:
        - apiKeyAuth: []
      responses:
        '200':
          description: Notification preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  email_enabled: { type: boolean }
                  telegram_enabled: { type: boolean }
                  telegram_chat_id: { type: [string, 'null'] }
                  webhook_url: { type: [string, 'null'] }
                  webhook_enabled: { type: boolean }
    patch:
      tags: [Notifications]
      summary: Update notification settings
      description: |
        Account-level notification configuration. Used by watchlist alert
        delivery (per-item `notify_email` / `notify_telegram` / `notify_webhook`
        flags decide which channels fire for that address).

        All fields are optional; PATCH semantics — provided fields are
        updated, omitted fields are preserved. Webhook delivery to the
        configured URL is signed with HMAC SHA-256 (`X-Signature-256` header)
        when `webhook_secret` is set. See `docs/integration/webhooks.md`.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Partial update. All fields optional. The values returned by
                `GET /api/notifications/settings` mirror the persisted state.
              properties:
                email_enabled:
                  type: boolean
                  description: Email channel master toggle.
                telegram_chat_id:
                  type: [string, 'null']
                  description: |
                    Telegram chat ID (numeric string). Set after the user
                    completes the Telegram connect flow via the dashboard.
                    Pass `null` to clear.
                telegram_enabled:
                  type: boolean
                  description: Telegram channel master toggle.
                webhook_url:
                  type: [string, 'null']
                  format: uri
                  description: |
                    HTTPS endpoint that will receive watchlist alert POSTs.
                    Pass `null` to clear.
                webhook_secret:
                  type: [string, 'null']
                  description: |
                    Shared secret used to compute the `X-Signature-256` HMAC
                    header on outbound webhooks. Recommended ≥32 characters.
                    Pass `null` to clear (webhooks will then be sent unsigned).
                webhook_enabled:
                  type: boolean
                  description: Webhook channel master toggle.
                daily_digest:
                  type: boolean
                  description: |
                    Collapse email alerts into one daily summary. Webhooks
                    remain instant regardless of this flag.
      responses:
        '200': { description: Settings saved }

  /api/notifications/test:
    post:
      tags: [Notifications]
      summary: Send test notification
      description: Sends a test alert through all enabled channels (email, telegram, webhook).
      security:
        - apiKeyAuth: []
      responses:
        '200':
          description: Test dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  email: { type: string, enum: [sent, skipped, failed] }
                  telegram: { type: string, enum: [sent, skipped, failed] }
                  webhook: { type: string, enum: [sent, skipped, failed] }

  /api/notifications/telegram/connect:
    post:
      tags: [Notifications]
      summary: Connect Telegram chat
      description: Submit chat ID obtained by messaging the OnChainRisk bot with /start and copying the code it returns.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chatId]
              properties:
                chatId: { type: string }
      responses:
        '200': { description: Telegram linked }
        '400': { description: Invalid chat ID }

  /api/notifications/telegram/disconnect:
    post:
      tags: [Notifications]
      summary: Disconnect Telegram
      security:
        - apiKeyAuth: []
      responses:
        '200': { description: Telegram unlinked }

  # ── Watchlist (extra) ─────────────────────────────────
  /api/watchlist/{id}:
    patch:
      tags: [Watchlist]
      summary: Update watchlist item
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string }
                notify_email: { type: boolean }
                notify_telegram: { type: boolean }
                notify_webhook: { type: boolean }
                threshold_value: { type: number }
      responses:
        '200': { description: Item updated }
        '404': { description: Item not found }
    delete:
      tags: [Watchlist]
      summary: Remove from watchlist
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200': { description: Item removed }

  /api/watchlist/{id}/alerts:
    get:
      tags: [Watchlist]
      summary: Alerts for a single watchlist item
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 100 }
      responses:
        '200':
          description: Paginated alerts for this item

  /api/alerts:
    get:
      tags: [Watchlist]
      summary: All alerts across user's watchlist
      security:
        - apiKeyAuth: []
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 50, maximum: 100 }
      responses:
        '200':
          description: Paginated alerts

  # ── Graph ─────────────────────────────────────────────
  /api/graph/expand:
    post:
      tags: [Graph]
      summary: Expand graph counterparties for an address (one-hop)
      description: |
        Returns direct counterparties for the given address as nodes and
        edges to merge into the client graph.

        **One-hop per call.** Multi-hop workflows are built by iterative
        client-side calls (call this endpoint per node you want to expand).
        There is no single-call multi-hop traversal today.

        **Billing.** Calls are logged as `usage` action `graph_expand`.
        They do **not** decrement credits and do **not** count against the
        monthly `/api/v1/check` quota. Per-plan rate limits still apply.

        See `docs/integration/capability-matrix.md` for supported networks
        and plan rate limits.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string }
                network: { type: string }
      responses:
        '200':
          description: |
            Expanded subgraph. `nodes` and `edges` are aggregated per
            counterparty (one edge per `(target, counterparty)` pair).

            `partial`/`fetchedTxs`/`totalTxs` appear when the upstream
            transaction fetch hit its per-network cap — the response
            still contains the top counterparties by aggregate value,
            just not derived from the full history.
          content:
            application/json:
              schema:
                type: object
                required: [nodes, edges]
                properties:
                  nodes:
                    type: array
                    items: { $ref: '#/components/schemas/GraphNode' }
                  edges:
                    type: array
                    items: { $ref: '#/components/schemas/GraphEdge' }
                  outcome:
                    type: string
                    enum: [complete, empty_terminal, partial]
                    description: |
                      Result classification for a successful (200) expansion:
                      `complete` (usable, full history), `empty_terminal` (the
                      provider reliably established there are no additional
                      connections), or `partial` (usable but incomplete).
                      A degraded/failed acquisition is NOT returned as 200 — it
                      surfaces as `503 EXPAND_UNAVAILABLE` (retryable). Optional
                      and additive; absent on older responses.
                  partial:
                    type: boolean
                    description: |
                      True when upstream tx fetch hit a per-network cap.
                      Absent when the full history was scanned.
                  fetchedTxs:
                    type: integer
                    description: Transactions actually fetched from the upstream provider. Present only when `partial = true`.
                  totalTxs:
                    type: integer
                    description: Reported total transaction count for the address (best-effort, may be 0 if unknown). Present only when `partial = true`.
        '503':
          description: |
            `EXPAND_UNAVAILABLE` — the expansion could not be reliably
            completed (provider timeout / rate-limit / degraded upstream, or a
            partial with zero usable data). Retryable: the node is not marked
            expanded and the result is not cached.

  /api/graph/csv-import:
    post:
      tags: [Graph]
      summary: Import CSV of transactions into a report graph
      description: Parses CSV (Etherscan-style export) and merges rows into the report's graph. Triggers full graph reboot in the UI.
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reportId, csv]
              properties:
                reportId: { type: string }
                csv: { type: string, description: "Raw CSV text" }
      responses:
        '200':
          description: Import result
          content:
            application/json:
              schema:
                type: object
                properties:
                  rowsParsed: { type: integer }
                  nodesAdded: { type: integer }
                  edgesAdded: { type: integer }
        '400': { description: CSV parse error }


  # ── Sharing (public) ──────────────────────────────────
  /api/share/{token}:
    get:
      tags: [Sharing]
      summary: Public report view by share token
      description: No authentication required. Returns the report if the share link is active and not expired.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Public report data
        '404': { description: Share link not found or expired }

  /api/share/{token}/graph:
    get:
      tags: [Sharing]
      summary: Public graph view by share token
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Public graph data
        '404': { description: Share link not found or expired }

  # ── Saved Reports ─────────────────────────────────────
  /api/v1/token/reports:
    post:
      tags: [Saved Reports]
      summary: Save a token security report
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network, reportData]
              properties:
                address: { type: string }
                network: { type: string }
                reportData: { type: object }
      responses:
        '201':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
    get:
      tags: [Saved Reports]
      summary: List saved token reports
      security:
        - apiKeyAuth: []
      parameters:
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 100 }
        - name: offset
          in: query
          description: Zero-based row offset. This surface pages by offset, not page number.
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: Saved token reports (offset-paged)

  /api/v1/block/reports:
    post:
      tags: [Saved Reports]
      summary: Save a block analysis (MEV) report
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blockNumber, network, reportData]
              properties:
                blockNumber: { type: integer }
                network: { type: string }
                reportData: { type: object }
      responses:
        '201':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
    get:
      tags: [Saved Reports]
      summary: List saved block reports
      security:
        - apiKeyAuth: []
      parameters:
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 100 }
        - name: offset
          in: query
          description: Zero-based row offset. This surface pages by offset, not page number.
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: Saved block reports (offset-paged)

  /api/v1/block/reports/{id}:
    get:
      tags: [Saved Reports]
      summary: Fetch a saved block (MEV) report by id
      description: |
        Retrieve one saved block report owned by the calling API key. Does NOT
        consume quota.

        `id` accepts the UUID `reportPublicId` returned by
        `POST /api/v1/block/analyze` (preferred), or the legacy integer report
        id. Returns 404 if the report does not exist or is not owned by the
        caller.
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report id — UUID reportPublicId (preferred) or legacy integer id.
          schema: { type: string }
      responses:
        '200':
          description: Saved block report
        '401':
          description: Unauthorized
        '404':
          description: Report not found or not owned by the caller

  /api/v1/multichain/reports:
    post:
      tags: [Saved Reports]
      summary: Save a multi-chain wallet report
      security:
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, reportData]
              properties:
                address: { type: string }
                reportData: { type: object }
      responses:
        '201':
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
    get:
      tags: [Saved Reports]
      summary: List saved multi-chain reports
      security:
        - apiKeyAuth: []
      parameters:
        - name: limit
          in: query
          description: Page size. Values above the maximum are clamped, not rejected.
          schema: { type: integer, default: 20, maximum: 100 }
        - name: offset
          in: query
          description: Zero-based row offset. This surface pages by offset, not page number.
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        '200':
          description: Saved multi-chain reports (offset-paged)

  /api/v1/multichain/reports/{id}:
    get:
      tags: [Saved Reports]
      summary: Fetch a saved multi-chain report by id
      description: |
        Retrieve one saved multi-chain wallet report owned by the calling API
        key. Does NOT consume quota.

        `id` accepts the UUID `reportPublicId` returned by
        `POST /api/v1/multichain/scan` (preferred), or the legacy integer
        report id. Returns 404 if the report does not exist or is not owned by
        the caller.
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Report id — UUID reportPublicId (preferred) or legacy integer id.
          schema: { type: string }
      responses:
        '200':
          description: Saved multi-chain report
        '401':
          description: Unauthorized
        '404':
          description: Report not found or not owned by the caller

  # ── MEV ───────────────────────────────────────────────
  /api/investigations/{id}:
    get:
      tags: [AI Investigation]
      summary: Get investigation detail
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Full investigation with messages and tool calls
        '404': { description: Not found }

    delete:
      tags: [AI Investigation]
      summary: Archive investigation
      description: >-
        Soft-delete. Hides the investigation from the default list but preserves its data.
        #729: previously published as `POST /api/investigations/{id}/archive`, which has no
        runtime route. Archiving has always been DELETE on the resource — the shape the
        dashboard calls.
      security:
        - apiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200': { description: Archived }
        '404': { description: Not found }

# ── Outbound webhook callbacks ──────────────────────────────────────────────
# OpenAPI 3.1 top-level `webhooks:` describes callbacks OnChainRisk sends
# OUT to customer-configured endpoints (registered via PATCH
# /api/notifications/settings). See docs/integration/webhooks.md for HMAC
# verification and delivery semantics (single-attempt today, ~5-min cadence).
webhooks:
  watchlistAlert:
    post:
      operationId: watchlistAlertWebhook
      # Outbound webhook — auth is HMAC SHA-256 (X-Signature-256), not the
      # apiKeyAuth scheme used by inbound endpoints. Empty list explicitly
      # opts out of root-level apiKeyAuth so codegen / lint don't expect it.
      security: []
      summary: Watchlist alert (outbound webhook)
      description: |
        Delivered to your configured `webhook_url` when a watchlist item
        sees a new on-chain transaction. Cron cadence ~5 minutes.

        **HMAC**: when `webhook_secret` is set, the `X-Signature-256` header
        contains `sha256=<hex>` over the raw request body bytes. Verify
        before processing — see docs/integration/webhooks.md for sample
        Node and Python verifiers.

        **Delivery semantics**: single attempt per alert today. If your
        endpoint is briefly unavailable the alert is logged on our side
        and not retried. Retries are roadmap.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatchlistAlertWebhookPayload'
      parameters:
        - in: header
          name: X-Signature-256
          required: false
          description: |
            HMAC SHA-256 of the raw request body, hex-encoded, prefixed
            with `sha256=`. Present only when `webhook_secret` was set
            via PATCH /api/notifications/settings. Strongly recommended
            to configure.
          schema:
            type: string
            example: "sha256=<hex_hmac_sha256_signature>"
      responses:
        '2xx':
          description: |
            Any 2xx response acknowledges receipt. We do not retry on
            non-2xx today (single-attempt delivery).

components:
  securitySchemes:
    apiKeyAuth:
      type: http
      scheme: bearer
      description: API key (create at https://app.onchainrisk.io/dashboard/api-keys)

  responses:
    ValidationFailed:
      description: |
        Address failed validation before any scan was performed. HTTP status
        is **422** with envelope `{ success: false, error: ErrorResponse }`.
        `error.details.errorCode` carries the granular reason.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, example: false }
              error: { $ref: '#/components/schemas/ErrorResponse' }
          examples:
            address-network-mismatch:
              summary: Tron address submitted with network=eth
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    address: "Address format does not match selected network. The address looks like a tron address, but network was set to eth."
                    errorCode: "ADDRESS_NETWORK_MISMATCH"
                    detectedFamily: "tron"
                    requestedNetwork: "eth"
            invalid-address:
              summary: Whitespace-only address
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    address: "Address is required."
                    errorCode: "INVALID_ADDRESS"
            unsupported-network:
              summary: network=Ethereum (not in whitelist)
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    address: "Unsupported network 'ethereum'. Supported: arbitrum, avalanche, base, bsc, btc, cardano, celo, cosmos, cronos, eth, fantom, linea, ltc, moonbeam, optimism, polygon, polygon_zkevm, scroll, sol, ton, tron, xrp, zksync."
                    errorCode: "UNSUPPORTED_NETWORK"
                    requestedNetwork: "ethereum"
            deep-analysis-unsupported-network:
              summary: Deep analysis (peel chain) not available for this network
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    network: "Deep analysis not yet supported for TRON. Supported: ETH, Arbitrum, Optimism, Base, Polygon, Avalanche, Linea, zkSync Era, Scroll."
                    errorCode: "DEEP_ANALYSIS_UNSUPPORTED_NETWORK"
                    requestedNetwork: "tron"
            could-not-detect-network:
              summary: Unrecognisable address, no network hint
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    address: "Could not detect network from address format. Please specify the network parameter."
                    errorCode: "COULD_NOT_DETECT_NETWORK"
            invalid-address-checksum:
              summary: Tron-shaped string with bad Base58Check
              value:
                success: false
                error:
                  message: "Validation failed"
                  code: "VALIDATION_ERROR"
                  details:
                    address: "Address failed tron checksum validation."
                    errorCode: "INVALID_ADDRESS_CHECKSUM"
                    detectedFamily: "tron"
                    requestedNetwork: "tron"

  schemas:
    Coverage:
      type: object
      description: |
        Per-chain coverage disclosure block. Present on every report response
        regardless of stage — clients should NEVER need to special-case its absence.

        Stage taxonomy:
          - `ga`       — General Availability: full historical depth within stated window.
          - `rollout`  — Active migration / soak window. Coverage windows may temporarily vary.
          - `preview`  — Pre-v1: reports may have incomplete depth or gaps.
          - `degraded` — Known operational incident; see `warning` and `incident_url`.

        **Client integration requirement:** clients integrating OnChainRisk are
        REQUIRED to surface the `coverage.disclosure` field to end users when
        `stage != ga`. For `stage in [preview, degraded]`, clients MUST surface
        the `warning` field prominently.

        Canonical per-chain status matrix: see the `matrix_url` link.
      required: [stage, window, data_path, as_of, disclosure, matrix_url]
      properties:
        stage:
          type: string
          enum: [ga, rollout, preview, degraded]
          description: |
            Coverage maturity for this chain.
        window:
          type: [string, 'null']
          example: "30d"
          description: |
            Trailing coverage window (e.g. "30d", "90d"). May be `null` for
            chains in `preview` stage where no window is yet committed.
        data_path:
          type: string
          example: "self_hosted_nitro_clickhouse"
          description: |
            Production data path label (informational; not a user-facing
            commitment). Subject to change without notice within the stated stage.
        as_of:
          type: string
          format: date
          example: "2026-05-04"
          description: ISO date when this coverage status was last verified.
        disclosure:
          type: string
          description: |
            Disclosure text appropriate for this stage. Clients SHOULD surface
            this to end users when `stage != ga`. For `ga` chains, this is the
            standard methodology footer reference.
        warning:
          type: string
          description: |
            Strong warning text. Present (REQUIRED) when `stage in [preview, degraded]`.
            Clients MUST surface this prominently. Absent for `stage in [ga, rollout]`.
        incident_url:
          type: string
          format: uri
          description: |
            Incident status URL. May be present when `stage = degraded`.
        matrix_url:
          type: string
          format: uri
          example: "https://onchainrisk.io/coverage"
          description: |
            Public canonical coverage matrix page. UI pills and clients
            integrating OnChainRisk should link here for current per-chain status.
        served_from:
          type: string
          enum: [hot, archive, direct_node, direct_api, mixed]
          description: |
            Optional hybrid read-model disclosure. Which read path served this
            report: `hot` (own indexer / hot store), `archive` (replayed from
            archive), `direct_node` (point node lookup), `direct_api`
            (third-party API fallback, e.g. Moralis), or `mixed` (hot/indexer
            data combined with a point lookup such as `eth_getCode` is_contract).
            Absent when the producer does not yet emit hybrid disclosure.
        historical_completeness:
          type: string
          enum: [within_window, replayed_from_archive, partial_via_fallback, unknown_before_window]
          description: |
            Optional hybrid read-model disclosure. Depth completeness of the
            served data: `within_window` (full depth within the stated window),
            `replayed_from_archive` (archive replay restored depth),
            `partial_via_fallback` (a third-party fallback supplied partial
            depth), or `unknown_before_window` (the indexer window limited how
            far back depth is known). Absent when not emitted by the producer.
        fallback_used:
          type: boolean
          description: |
            Optional hybrid read-model disclosure. `true` when a third-party /
            direct-API fallback (e.g. Moralis) supplied the analytical data.
            A point lookup such as `eth_getCode` / is_contract alone does NOT
            set this. Absent when not emitted by the producer.
        source_mode:
          type: string
          description: |
            Optional hybrid read-model disclosure. Free-form source label, e.g.
            `mixed` (hot indexer + point lookup), `hot:<data_path>`, or
            `fallback:moralis`. Informational; not an enum. Absent when not
            emitted by the producer.
        coverage_window:
          type: [string, 'null']
          example: "30d"
          description: |
            Optional alias of `window` — same value, provided for hybrid
            read-model consumers that key on `coverage_window`. Does NOT change
            the meaning of `window`. Absent when not emitted by the producer.
      example:
        stage: rollout
        window: "30d"
        data_path: self_hosted_nitro_clickhouse
        as_of: "2026-05-04"
        disclosure: "OnChainRisk's multi-chain coverage is in active rollout..."
        matrix_url: https://onchainrisk.io/coverage
        served_from: hot
        historical_completeness: within_window
        fallback_used: false
        source_mode: "hot:self_hosted_nitro_clickhouse"
        coverage_window: "30d"

    GraphNode:
      type: object
      description: |
        Counterparty node in the paid graph response. One node per
        unique counterparty address aggregated from the target's
        transaction history.
      required: [address, txCount]
      properties:
        address:
          type: string
          description: Counterparty address (case preserved per network — hex addresses returned in lowercase).
        label:
          type: [string, 'null']
          description: |
            Human-readable label from the OnChainRisk label database
            (exchange names, mixer/bridge tags, custom user labels, etc.)
            or `null` when no label matches.
        isTarget:
          type: boolean
          description: |
            Always `false` for counterparty nodes returned by this
            endpoint. Reserved for graph-rendering clients that may
            also represent the queried address as a node — the API
            does not currently emit a target node, but client code
            should treat unknown values defensively.
        totalValue:
          type: string
          description: |
            Aggregate native value across all transactions with this
            counterparty, formatted with the network's native unit
            (e.g. `"1.5 ETH"`, `"42 BNB"`, `"100 TRX"`). String to
            preserve precision across networks.
        txCount:
          type: integer
          minimum: 0
          description: Total transactions between target and this counterparty in the fetched history.
        tokens:
          type: array
          description: |
            Token symbols flowing through this counterparty (capped at
            the top-N by volume per the analyzer). May be empty when
            no token transfers were observed or the network has no
            token analyzer wired.
          items:
            type: string

    GraphEdge:
      type: object
      description: |
        Aggregated edge between target and one counterparty. Each
        `(target, counterparty)` pair produces exactly one edge —
        all transactions in the same direction-dominant flow are
        summed. Direction is inferred: edge points from the side
        that contributed more native value.
      required: [from, to, value, valueWei, txCount]
      properties:
        from:
          type: string
          description: Source address (target or counterparty depending on net flow direction).
        to:
          type: string
          description: Destination address.
        fromLabel:
          type: [string, 'null']
          description: Label of the `from` address (counterparty label when the counterparty was the sender; otherwise `null`).
        toLabel:
          type: [string, 'null']
          description: Label of the `to` address (counterparty label when the counterparty was the recipient; otherwise `null`).
        value:
          type: string
          description: Native value formatted with unit (e.g. `"1.5 ETH"`).
        valueWei:
          type: string
          description: |
            Native value in raw integer units (wei for EVM, sun for
            Tron, satoshi for BTC/LTC, lamports for SOL, etc.). String
            to preserve precision beyond JS Number range.
        txCount:
          type: integer
          minimum: 1
          description: Number of transactions aggregated into this edge.
        firstTx:
          type: integer
          description: |
            Unix timestamp (seconds) of the earliest transaction
            aggregated into this edge. May be `0` when the upstream
            analyzer was unable to determine — treat `0` as "unknown",
            not "epoch zero".
        lastTx:
          type: integer
          description: |
            Unix timestamp (seconds) of the latest transaction
            aggregated into this edge. Same `0`-as-unknown caveat as
            `firstTx`.
        tokenBreakdown:
          type: array
          description: |
            Per-token volume context for transactions on this edge.
            Common fields per item include `symbol` (string), `value`
            (string, formatted), and `txCount` (integer); additional
            per-network fields may be present. **Treat the item shape
            as open** — clients should defensively handle unknown
            properties and not assume a closed schema.
          items:
            type: object

    WatchlistAlertWebhookPayload:
      type: object
      description: |
        Body sent to a customer-configured webhook URL when a watchlist
        item sees a new transaction. Stable top-level fields are listed
        below; nested `data.*` fields evolve and clients should parse
        defensively (do not assume the shape is closed).
      required:
        - type
        - alert_id
        - address
        - network
      properties:
        type:
          type: string
          enum: [watchlist_alert]
          description: Webhook event type. Currently only `watchlist_alert`.
        alert_id:
          type: integer
          description: Stable per-alert ID; usable as idempotency key.
        watchlist_id:
          type: integer
          description: ID of the watchlist item that fired this alert.
        address:
          type: string
          description: The watched address.
        network:
          type: string
          description: Network identifier (e.g. `eth`, `bsc`, `tron`).
        alert_type:
          type: [string, 'null']
          description: |
            High-level alert category. Examples seen in production:
            `incoming`, `outgoing`, `balance_change`. Treat as
            open-ended — new types may be added without spec bump.
        tx_hash:
          type: [string, 'null']
          description: Transaction hash (when applicable).
        explorer_url:
          type: [string, 'null']
          format: uri
          description: Pre-built explorer link for the transaction.
        value:
          type: [string, 'null']
          description: Native value, formatted as a string for precision.
        timestamp:
          type: [integer, 'null']
          description: Unix seconds when the alert was emitted.
        data:
          type: object
          description: |
            Nested context object — fields evolve. Currently includes
            denormalized copies of `tx_hash`, `explorer_url`, `network`,
            `address`, plus a `tx` sub-object for transaction details
            and `alertType`. Parse defensively.
      example:
        type: watchlist_alert
        alert_id: 12345
        watchlist_id: 678
        address: "0xYourAddress"
        network: eth
        alert_type: incoming
        tx_hash: "0xabc123..."
        explorer_url: "https://etherscan.io/tx/0xabc123..."
        value: "1.234"
        timestamp: 1777800000
        data:
          address: "0xYourAddress"
          network: eth
          alertType: incoming
          tx:
            hash: "0xabc123..."
            value: "1.234"
            from: "0xSender..."
            to: "0xYourAddress"
          tx_hash: "0xabc123..."
          explorer_url: "https://etherscan.io/tx/0xabc123..."

    ErrorResponse:
      type: object
      description: |
        Stable error envelope for validation and business-rule failures.
        The shape mirrors the live API response:

        ```json
        { "success": false, "error": ErrorResponse }
        ```

        Required fields (`code` + `message`) are always populated; `details`
        carries structured diagnostic context and is present on validation
        failures. Clients should treat unknown fields as additive
        (no `additionalProperties: false`).
      required: [code, message]
      properties:
        code:
          type: string
          description: Outer envelope code. Always `VALIDATION_ERROR` for the validation family.
          enum:
            - VALIDATION_ERROR
        message:
          type: string
          description: Short, stable human-readable summary.
        details:
          type: object
          description: |
            Granular context. For validation failures the shape below is stable;
            for other error classes the API may return different keys.
          properties:
            address:
              type: string
              description: Human-readable reason specific to the failing address.
            errorCode:
              type: string
              description: |
                Granular validation reason. Stable machine-readable code.

                  - `INVALID_ADDRESS` — empty, wrong format, whitespace-only.
                  - `UNSUPPORTED_NETWORK` — `network` not in the supported list.
                  - `COULD_NOT_DETECT_NETWORK` — address format unrecognised and `network` omitted.
                  - `INVALID_ADDRESS_CHECKSUM` — decode succeeded, family checksum failed (Tron / BTC-legacy / LTC-legacy). Solana decode failures surface as `INVALID_ADDRESS` because Solana is not checksum-based.
                  - `ADDRESS_NETWORK_MISMATCH` — detected family inconsistent with requested `network`.
              enum:
                - INVALID_ADDRESS
                - UNSUPPORTED_NETWORK
                - COULD_NOT_DETECT_NETWORK
                - INVALID_ADDRESS_CHECKSUM
                - ADDRESS_NETWORK_MISMATCH
            detectedFamily:
              type: [string, 'null']
              description: |
                Address family detected from the input. Null when the format
                was unrecognisable. ENS names are resolved through a legacy
                path and are intentionally not part of the strict family
                contract documented here.
            requestedNetwork:
              type: [string, 'null']
              description: Normalized network string from the request, if any.

    ExchangeDepositsValid:
      type: object
      description: >-
        Valid exchange-deposit summary. Exchange-identity -> count only;
        untrusted token symbols are never part of the breakdown keys. Legacy
        valid objects omit `status`.
      required: [totalDeposits, totalValue, exchangeBreakdown, flags]
      properties:
        totalDeposits: { type: integer, minimum: 0 }
        totalValue: { type: string }
        exchangeBreakdown:
          type: object
          additionalProperties: { type: integer, minimum: 0 }
        exchangeAssetBreakdown:
          type: array
          items: { type: object }
        flags:
          type: array
          items: { type: string }
    ExchangeDepositsInvalidated:
      type: object
      description: >-
        M2-C2 sentinel written when a persisted report's historical exchange
        attribution can no longer be truthfully reconstructed from retained
        data. Carries no totalDeposits/totalValue/exchangeBreakdown/flags — the
        prior numeric attribution must not be retained as fact. Never produced
        by live analysis.
      required: [status, curation]
      properties:
        status: { type: string, enum: [historical_attribution_invalidated] }
        curation: { type: string, minLength: 1, example: "M2-C2-2026-08-04" }
    AnalysisResult:
      type: object
      properties:
        address: { type: string }
        network: { type: string }
        riskScore: { type: integer, minimum: 0, maximum: 100, nullable: true, description: "Null while async scan is queued/analyzing." }
        riskLevel: { type: string, enum: [critical, high, medium, low], nullable: true }
        riskContributions:
          type: object
          nullable: true
          additionalProperties:
            type: integer
            minimum: 0
            maximum: 100
          description: |
            Per-component risk contribution scores, 0-100. Keys are the
            seven scorer components (mixer_interaction, sanctioned_contact,
            behavioral_patterns, counterparty_risk, timing_anomalies,
            contract_risk, fund_flow_topology). Null on pre-explainability
            Modal versions. Honest invariant:
              base_score ≈ round(Σ riskContributions[k] × riskWeights[k])
            The final `riskScore` may still be higher than base_score
            because critical override floors (sanctioned, exploit, frozen,
            mixer, transit_massive, ...) are applied separately. Surfacing
            which floor fired is planned for a future PR.
        riskWeights:
          type: object
          nullable: true
          additionalProperties:
            type: number
            minimum: 0
            maximum: 1
          description: |
            Effective per-component weights actually used for THIS scoring
            run, after custom_weights validation/normalization and EOA
            redistribution. NOT the static module-default weights.

            **EOA redistribution rule:** when the analyzed target is an
            externally-owned account (EOA), the `contract_risk` weight is
            zeroed and its share is proportionally redistributed across
            `mixer_interaction`, `sanctioned_contact`, and
            `behavioral_patterns`. A user who PATCHed their saved
            `custom_weights` with e.g. `contract_risk: 0.20` will therefore
            see `riskWeights.contract_risk: 0` on an EOA target — the
            value was applied, then redistributed by the scorer. This
            transformation is intentional and only affects EOA targets;
            contract targets see weights as PATCHed. (F-R — #52.2 docs
            hardening.)

            Sum of values is 1.0 (within float tolerance). Keys are the
            same seven components as `riskContributions` so the two maps
            can be zipped client-side. Null on pre-explainability Modal
            versions.
        riskReasons:
          type: array
          description: |
            Ranked, typed reasons explaining the `riskScore`. Each entry
            is either a `floor` (an override floor that was applied) or
            a `component` (a non-zero per-component contribution to the
            base score).

            Sort order is deterministic and stable across runs:
              1. `impactScore` descending,
              2. `kind` ascending (`floor` before `component`),
              3. `code` ascending alphabetical.

            Empty array when `riskScore` is `null` (insufficient data)
            or when no signal exceeded the minimum-impact threshold.
            Older servers may omit the field entirely — clients should
            default to `[]` if absent.

            Floor reasons carry the matching flag subset in
            `supportingFlags`. Component reasons emit `supportingFlags: []`
            in this PR; per-component flag attribution is planned for a
            later PR.
          items:
            $ref: '#/components/schemas/RiskReason'
        riskConfidence:
          type: string
          enum: [high, medium, low]
          description: |
            Confidence in the reported `riskScore` based on data
            completeness. **Behavior change**: this enum was previously
            `[high, low]` and now includes `medium`. Tier semantics:
              - `high`: fully complete tx fetch, exact funded_by, no
                token-flow extraction crash, no coverage downgrade.
              - `medium`: partial-but-substantial data
                (`completeness_pct ≥ 60`), or any tx-count coverage
                downgrade (approximate / subset_only / unknown), or
                `token_flow_incomplete`, or `funded_by_status` in
                {approximate, unavailable}.
              - `low`: `riskScore` was nulled due to low completeness,
                or provider returned `api_error`, or partial-fetch with
                `completeness_pct < 60` (or unknown).
            See `riskConfidenceDetails` for the specific reasons that
            drove the tier on this particular response.
        riskConfidenceDetails:
          type: object
          nullable: true
          description: |
            Structured explanation of why a particular `riskConfidence`
            tier was selected. Null on pre-explainability Modal versions
            (in that case `riskConfidence` is still emitted with a tier
            value, just without the breakdown).
          properties:
            tier:
              type: string
              enum: [high, medium, low]
              description: Same value as `riskConfidence`.
            completeness_pct:
              type: number
              nullable: true
              minimum: 0
              maximum: 100
              description: |
                Fraction of reported transaction history actually
                fetched. Null when the upstream provider doesn't expose
                a reliable total (some non-EVM chains).
            reasons:
              type: array
              items:
                type: string
                enum:
                  - tx_count_coverage_subset
                  - tx_count_coverage_approximate
                  - tx_count_coverage_unknown
                  - tx_fetch_incomplete_limit_cap
                  - tx_fetch_incomplete_api_error
                  - token_flow_incomplete
                  - funded_by_approximate
                  - funded_by_unavailable
                  - score_unavailable_low_completeness
              description: |
                Public-contract identifiers for each completeness signal
                that contributed to the tier. Order is deterministic
                (alphabetical) and the list is deduplicated. Empty when
                tier is `high`. A `low` tier may include medium-level
                reasons too — clients should render the full list rather
                than only the most-severe entry.
        reportId: { type: string, format: uuid }
        status:
          $ref: '#/components/schemas/ReportStatus'
        errorMessage:
          type: string
          nullable: true
          description: Populated when `status='failed'`.
        createdAt:
          type: string
          format: date-time
          description: Initial creation timestamp of this report row.
        addressInfo:
          type: object
          properties:
            type: { type: string, enum: [eoa, contract, smart_contract, unknown], description: "Address type. `contract` and `smart_contract` are aliases — both mean a deployed contract; clients should treat them equivalently." }
            balance: { type: string, example: "1.5 ETH" }
            balanceUsd: { type: number, nullable: true, example: 3045.50 }
            nativePriceUsd: { type: number, nullable: true, example: 2030.33 }
            txCount: { type: integer }
            firstSeen: { type: string, nullable: true }
            lastSeen: { type: string, nullable: true }
        patternFlags:
          type: array
          items: { type: string }
          description: |
            Legacy display strings, kept for backward compatibility. For
            localization use `code` / `templateKey` from `patternFlagDetails`
            (index-aligned with this array).
          example: ["BULK COLLECTION pattern: 24 inbound TXs in 600s", "OFAC/sanctions list match"]
        patternFlagDetails:
          type: array
          description: |
            Localization-friendly metadata, index-aligned with `patternFlags`.
            Use `code` / `templateKey` for translation/mapping. `defaultEn` is
            a display fallback only (may include dynamic values; not stable
            across releases).
          items:
            $ref: '#/components/schemas/FlagDetail'
          example:
            - code: bulk_collection
              templateKey: pattern.bulk_collection
              defaultEn: "BULK COLLECTION pattern: 24 inbound TXs in 600s"
              severity: medium
              description: "Many sources sent funds within a short window (bulk collection)."
            - code: ofac_sanctions_match
              templateKey: pattern.ofac_sanctions_match
              defaultEn: "OFAC/sanctions list match"
              severity: critical
              description: "Address matches an OFAC/sanctions list."
        topCounterparties:
          type: array
          items:
            type: object
            properties:
              address: { type: string }
              label: { type: string, nullable: true }
              category: { $ref: '#/components/schemas/CategoryEnum' }
              txCount: { type: integer }
              totalValue: { type: string }
              inbound: { type: boolean }
        addressConnections:
          type: array
          description: |
            Aggregated counterparty category breakdown by total interaction volume (inbound + outbound)
            across up to 30 counterparties returned by the analyzer. Percentages rounded to 1 decimal,
            sum may be 99.0–101.0 due to rounding.
          items:
            $ref: '#/components/schemas/AddressConnection'
          example:
            - category: exchange
              percentage: 64.0
              valueFormatted: "15,000 TRX"
              valueRaw: "15000000000"
              unit: sun
            - category: sanctioned
              percentage: 23.9
              valueFormatted: "5,600 TRX"
              valueRaw: "5600000000"
              unit: sun
            - category: unknown
              percentage: 12.1
              valueFormatted: "2,835 TRX"
              valueRaw: "2835000000"
              unit: sun
        fundedBy:
          type: object
          nullable: true
          properties:
            address: { type: string }
            label: { type: string, nullable: true }
            txHash: { type: string }
            timestamp: { type: integer }
            value: { type: string }
        timing:
          type: object
          nullable: true
          description: |
            Transaction timing analysis. Includes legacy `botFlags` (display
            strings) and `botFlagDetails` (localization-friendly metadata,
            index-aligned with `botFlags`). Other fields (hourly/daily
            distribution, regularityScore, etc.) are returned as-is from
            the analyzer.
          properties:
            botFlags:
              type: array
              items: { type: string }
              description: Legacy display strings, kept for backward compatibility. For localization use `code` / `templateKey` from `botFlagDetails`.
              example: ["Active across 22+ hours daily — likely automated"]
            botFlagDetails:
              type: array
              description: Localization-friendly metadata, index-aligned with `botFlags`.
              items:
                $ref: '#/components/schemas/FlagDetail'
              example:
                - code: always_on_24h
                  templateKey: bot.always_on_24h
                  defaultEn: "Active across 22+ hours daily — likely automated"
                  severity: medium
                  description: "Address is active for ~22+ hours per day, suggesting automation."
        crossChain: { type: object, nullable: true }
        exchangeDeposits:
          oneOf:
            - $ref: '#/components/schemas/ExchangeDepositsValid'
            - $ref: '#/components/schemas/ExchangeDepositsInvalidated'
            - type: 'null'
          description: >-
            A valid exchange-deposit summary, an M2-C2
            historical-attribution-invalidated sentinel (persisted reports only;
            never produced by live analysis), or null. Legacy valid objects do
            not carry a `status` field. Structurally distinguishable without a
            discriminator: the invalidated variant has `status` + `curation` and
            no numeric fields; the valid variant has the numeric fields.
        destinationEntities:
          type: array
          description: >-
            Per-transfer destination-entity evidence. Always an array: it is []
            when unavailable, in sandbox, or when the report owner's current
            effective plan is not entitled to destination_entity_evidence.
            Internal dedup identifiers are never exposed.
          items:
            $ref: '#/components/schemas/DestinationEntityRecord'
        destinationEntitySummary:
          oneOf:
            - $ref: '#/components/schemas/DestinationEntitySummary'
            - type: 'null'
          description: >-
            Aggregate over detected destination entities, or null when
            unavailable, in sandbox, or the owner's current effective plan is
            not entitled.
        defiSwaps: { type: object, nullable: true }
        nftWashTrading: { type: object, nullable: true }
        osintIntelligence: { type: object, nullable: true }
        tokenFlow: { type: object, nullable: true }
        contractAnalysis:
          $ref: '#/components/schemas/ContractAnalysis'
        contractInfo:
          $ref: '#/components/schemas/ContractInfo'
        dataCompleteness:
          $ref: '#/components/schemas/DataCompleteness'
        honeypot:
          oneOf:
            - $ref: '#/components/schemas/Honeypot'
            - type: 'null'
        railgun: { type: object, nullable: true }
        clusters: { type: object, nullable: true }
        usdPrices: { type: object, nullable: true }
        tokenTransferSupported: { type: boolean, nullable: true, description: "Whether token transfer data is available for this network" }
        tokenFlowIncomplete: { type: boolean, nullable: true, description: "True if token flow data is partial due to API failures" }
        walletArchetype: { type: string, nullable: true, enum: [automated, distribution, exchange_like, mev_like, unclassified], description: "SOL wallet behavior classification" }
        partialReason:
          type: string
          nullable: true
          enum: [timeout, limit_cap, limit_cap_assumed, api_error, btc_tx_count_exceeds_full_fetch_limit, ltc_tx_count_exceeds_full_fetch_limit, heavy_contract_overview]
          description: |
            Reason for partial transaction data. Mirrors `dataCompleteness.partialReason` for clients
            that only inspect the top level. `heavy_contract_overview` indicates the address is a
            deployed contract with `txCount >= 50000` and the analyzer returned bytecode-level
            metadata only — full transaction-level tracing is not available for this contract.
        analysisTime: { type: integer, description: "Milliseconds" }
        timestamp: { type: string, format: date-time }

    ReportStatus:
      type: string
      description: |
        Lifecycle of an async report:
          `queued`    — POST /check accepted; consumer worker has not picked it up yet.
          `analyzing` — consumer started; partial fields may exist.
          `complete`  — analysis written; full result available via GET /api/reports/{reportId}.
          `failed`    — terminal error; `errorMessage` populated.
      enum: [queued, analyzing, complete, failed]

    AsyncQueuedResponse:
      type: object
      description: |
        202 response from POST /api/v1/check when the analysis takes the async
        path (heavy address). The reportId is stable across the in-flight window —
        a deduplicated retry returns the same id with `deduplicated: true`.
      required: [success, data]
      properties:
        success: { type: boolean, example: true }
        data:
          type: object
          required: [reportId, status, async]
          properties:
            reportId:
              type: string
              format: uuid
              description: Public id of the queued report. Use with /api/v1/check/status/{reportId}.
            status:
              type: string
              enum: [queued]
              description: Initial status — always `queued` at the 202 boundary.
            async:
              type: boolean
              enum: [true]
              description: Discriminator. Always true on this response.
            deduplicated:
              type: boolean
              description: |
                True when this 202 returned the existing in-flight report's id
                instead of creating a new one. Within a 30-minute window, repeat
                POSTs for the same `(api-key, address, network)` are idempotent
                and don't create new charges or queue jobs.

    ScanStatus:
      type: object
      description: |
        Polling response from GET /api/v1/check/status/{reportId}. Lightweight
        summary safe to poll every ~5 seconds. Once `status === 'complete'`,
        the full report is available via GET /api/reports/{reportId}.
      required: [reportId, status]
      properties:
        reportId: { type: string, format: uuid }
        status:
          $ref: '#/components/schemas/ReportStatus'
        phase:
          type: string
          nullable: true
          description: |
            Free-form phase label set by the consumer worker while
            `status='analyzing'` (e.g. `fetching_transactions`). Cleared
            (`null`) on completion. Treat as observability hint, not API.
        errorMessage:
          type: string
          nullable: true
          description: Populated when `status='failed'`.
        riskScore:
          type: integer
          nullable: true
          minimum: 0
          maximum: 100
          description: Set when `status='complete'`. `null` otherwise.
        riskLevel:
          type: string
          nullable: true
          enum: [critical, high, medium, low]
        queuedAt:    { type: string, format: date-time, nullable: true }
        startedAt:   { type: string, format: date-time, nullable: true }
        finishedAt:  { type: string, format: date-time, nullable: true }

    DestinationEntityRecord:
      type: object
      description: >-
        One direct single-hop OUTGOING transfer to a labelled destination
        entity. Amounts are integer base units encoded as decimal strings.
        Internal dedup identifiers and debug fields are never present.
      properties:
        displayLabel: { type: string }
        entityName:
          type: string
          nullable: true
          description: Structured entity name when available; otherwise null.
        entityType:
          type: string
          enum:
            - centralized_exchange
            - vasp
            - otc_desk
            - custodian
            - payment_processor
            - fiat_on_off_ramp
            - other_service
        destinationAddress: { type: string }
        transactionHash: { type: string }
        timestamp: { type: integer }
        network: { type: string }
        direction: { type: string, enum: [outgoing] }
        hopCount: { type: integer }
        amountRaw:
          type: string
          description: >-
            Integer base-unit amount as an exact decimal string (never a JSON
            number — precision above 2**53 would be lost).
        amountFormatted: { type: string }
        asset: { type: string }
        assetType: { type: string, enum: [native, token] }
        assetAddress:
          type: string
          nullable: true
          description: Token contract/mint; null for native assets.
        assetDecimals: { type: integer }
        labelSource: { type: string }
        attributionBasis:
          type: string
          enum: [provenance_backed, manual_known_address, user_custom]
        confidenceLevel:
          type: string
          nullable: true
          enum: [high, medium, low, null]
        attributionEvidenceType: { type: string, nullable: true }
    DestinationEntityAssetTotal:
      type: object
      description: Per-asset total; native and token base units are never summed.
      properties:
        asset: { type: string }
        assetType: { type: string, enum: [native, token] }
        assetAddress: { type: string, nullable: true }
        assetDecimals: { type: integer }
        amountRaw:
          type: string
          description: Integer base-unit total as an exact decimal string.
        amountFormatted: { type: string }
        transferCount: { type: integer }
    DestinationEntityHistoryDisclosure:
      type: object
      description: >-
        Transaction-history completeness disclosure, derived only from scan-wide
        completeness state. null/where-unknown values are honest.
      properties:
        complete: { type: boolean }
        transactionHistoryComplete: { type: boolean }
        tokenHistoryComplete:
          type: boolean
          nullable: true
          description: true, false, or null when token transfers are not applicable.
        transactionCoverage:
          type: string
          enum: [exact, approximate, subset_only, unknown]
        transactionFetchIncomplete: { type: boolean }
        tokenTransferSupported: { type: boolean }
        tokenFlowIncomplete: { type: boolean }
        analyzedTransactions: { type: integer }
        totalTransactions: { type: integer, nullable: true }
        completenessPct: { type: number, nullable: true }
        partialReason: { type: string, nullable: true }
        tokenPartialReason: { type: string, nullable: true }
        windowDescription: { type: string }
        note: { type: string }
    DestinationEntitySummary:
      type: object
      description: Aggregate over detected destination entities.
      properties:
        assetTotals:
          type: array
          items:
            $ref: '#/components/schemas/DestinationEntityAssetTotal'
        totalDetected: { type: integer }
        recordsReturned: { type: integer }
        truncated: { type: boolean }
        historyDisclosure:
          oneOf:
            - $ref: '#/components/schemas/DestinationEntityHistoryDisclosure'
            - type: 'null'
    ContractAnalysis:
      type: object
      nullable: true
      description: |
        Contract bytecode analysis. Returned for deployed contracts (`addressInfo.type` =
        `contract` or `smart_contract`); `null` for EOAs and addresses with no bytecode.

        **Stable shape contract** — `similarityFlags` and `knownSignatures` are always
        arrays (possibly empty). For heavy contracts (`txCount >= 50000`) the analyzer
        returns a minimal object with bytecode metadata only; backend normalizes the
        shape so consumers can rely on these fields existing without runtime guards.
      properties:
        contractName: { type: string, nullable: true, example: "Router" }
        bytecodeSize:
          type: integer
          nullable: true
          description: "Bytecode length in bytes. `null` when unknown — clients should render `Unknown`, never `0`."
          example: 6177
        compilerVersion: { type: string, nullable: true, example: "v0.8.16+commit.07a7930e" }
        deployer: { type: string, nullable: true, example: "0xabc..." }
        isVerified: { type: boolean, example: true }
        hasSelfdestruct: { type: boolean }
        hasDelegatecall: { type: boolean }
        hasCreate2: { type: boolean }
        similarityFlags:
          type: array
          items: { type: string }
          description: "Pattern matches against known exploit fingerprints. Empty array when none."
        knownSignatures:
          type: array
          items: { type: string }
          description: "Decoded function signatures recognized in bytecode. Empty array when none."

    ContractInfo:
      type: object
      nullable: true
      description: "Deployment + proxy metadata. Distinct from `contractAnalysis` (bytecode analysis)."
      properties:
        deployer: { type: string, nullable: true }
        creationTx: { type: string, nullable: true }
        creationBlock: { type: integer, nullable: true }
        isProxy:
          type: boolean
          description: "True when the contract is a proxy (EIP-1967 / minimal proxy / etc.) verified against on-chain implementation slot. Inferred-only fallbacks are NOT reflected here."
        implementationAddress: { type: string, nullable: true, description: "Set when `isProxy` is true and the implementation is resolvable." }
        contractName: { type: string, nullable: true }

    DataCompleteness:
      type: object
      nullable: true
      description: "Transaction fetch completeness signals. Drives partial-data UX and `heavy_contract_overview` marker."
      properties:
        reportedTxCount: { type: integer, description: "Total tx count as reported by chain stats / explorer." }
        fetchedTxCount: { type: integer, description: "Number of transactions actually parsed end-to-end." }
        completenessPct: { type: number, nullable: true, description: "100 × fetched / reported, rounded; `null` if reported is 0." }
        partialReason:
          type: string
          nullable: true
          enum: [timeout, limit_cap, limit_cap_assumed, api_error, btc_tx_count_exceeds_full_fetch_limit, ltc_tx_count_exceeds_full_fetch_limit, heavy_contract_overview]
          description: |
            Why the data is partial. `heavy_contract_overview` is set automatically when the
            address is a deployed contract with `txCount >= 50000` AND the analyzer returned
            a minimal contract metadata shape (bytecode only). UIs should show "overview only"
            copy in this case rather than treating absence of activity as no-activity.

    CategoryEnum:
      type: string
      description: Counterparty category from label database
      enum:
        - exchange
        - dex
        - defi
        - bridge
        - otc
        - mev_bot
        - instant_exchange
        - phishing
        - mixer
        - scam
        - exploit
        - sanctioned
        - frozen
        - custodian
        - fiat_ramp
        - payment_processor
        - unknown

    RiskReason:
      type: object
      required:
        - code
        - kind
        - severity
        - title
        - templateKey
        - description
        - impactScore
        - supportingFlags
        - evidence
      description: |
        One ranked, typed reason contributing to the final `riskScore`.
        See the `riskReasons` field on `AnalysisResult` for ordering
        semantics. The `code` enum is locked as part of the public
        contract — additions require explicit review.
      properties:
        code:
          type: string
          description: |
            Stable machine-readable identifier. Locked enum.

            **Floor codes** (prefix `floor_`) — an override-floor that
            was applied to `riskScore` because a high-severity signal
            fired. Each floor reason carries the matching flag subset
            in `supportingFlags`.

            **Customizable floors (14)** — the floors below the
            `floor_honeypot` / `floor_excessive_tax` /
            `floor_low_token_score` group are user-customizable via
            `PATCH /api/user/risk-settings` (`overrides` field) on the
            Business plan. The 14 customizable keys are: `sanctioned`,
            `exploit`, `scam`, `phishing`, `frozen`, `mixer`,
            `perm_hijack`, `transit`, `transit_massive`, `railgun`,
            `railgun_low_anon`, `campaign`, `campaign_heavy`,
            `flag_density`. Worker / Modal / Dashboard align on this
            14-key set as of 2026-05-20 (PR #77 / PR #78).

            **Token-security floors (3, hardcoded)** —
            `floor_honeypot`, `floor_excessive_tax`,
            `floor_low_token_score` are not part of the customizable
            surface. Their floor values are fixed (80 / 70 / 60) and
            cannot be tuned via `custom_overrides`. (F-I — #52.2 docs
            hardening; revisit if user signal warrants promotion to
            customizable.)

            **Component codes** — the seven scorer components (same
            keys as `riskContributions` / `riskWeights`). Each
            component reason represents a non-zero contribution to the
            base score. Component reasons emit `supportingFlags: []`
            in this version.
          enum:
            - floor_sanctioned
            - floor_exploit
            - floor_scam
            - floor_phishing
            - floor_frozen
            - floor_mixer
            - floor_perm_hijack
            - floor_transit
            - floor_transit_massive
            - floor_railgun
            - floor_railgun_low_anon
            - floor_campaign
            - floor_campaign_heavy
            - floor_flag_density
            - floor_honeypot
            - floor_excessive_tax
            - floor_low_token_score
            - mixer_interaction
            - sanctioned_contact
            - behavioral_patterns
            - counterparty_risk
            - timing_anomalies
            - contract_risk
            - fund_flow_topology
        kind:
          type: string
          enum: [floor, component]
          description: |
            `floor` — an override-floor was applied (the score is
            forced to at least `impactScore`).

            `component` — a non-zero per-component contribution to the
            base score with magnitude `impactScore`.
        severity:
          type: string
          enum: [critical, high, medium, low]
          description: |
            Bucket for the reason's significance. `critical` is reserved
            for sanctioned / exploit / scam floor reasons; component
            reasons never surface as `critical`.
        title:
          type: string
          description: Short human-readable English label.
          example: "Sanctioned address — score floor applied"
        templateKey:
          type: string
          description: |
            Namespaced i18n key. `reason.floor.<code-suffix>` for floor
            reasons, `reason.component.<code>` for component reasons.
          example: reason.floor.sanctioned
        description:
          type: string
          description: |
            Short stable English explanation. May include dynamic
            integers (e.g. the floor minimum that was applied). Do not
            use as a translation key.
          example: "Address is on a sanctions / OFAC list. The risk score is forced to at least 95."
        impactScore:
          type: integer
          minimum: 0
          maximum: 100
          description: |
            Floors: the minimum score the floor enforced (e.g. 95 for
            sanctioned). Components: `round(riskContributions[code] *
            riskWeights[code])`, i.e. the honest contribution to the
            base score before any floor. Always used as the primary
            ranking key (DESC).
        supportingFlags:
          type: array
          items: { type: string }
          description: |
            For floor reasons, the subset of `flags` (verbatim rendered
            strings) that matched the floor's predicate. For component
            reasons in this version, always an empty array.
        evidence:
          type: array
          items:
            $ref: '#/components/schemas/RiskEvidence'
          description: |
            Task #63 PR 4 — structured observations supporting this
            reason. Always an array; never null. Phase 1 populates
            evidence rows for **floor-level reasons only**; component
            reasons (`mixer_interaction`, `sanctioned_contact`,
            `behavioral_patterns`, `counterparty_risk`,
            `timing_anomalies`, `contract_risk`, `fund_flow_topology`)
            return `evidence: []` in this version — that surface is
            planned for a follow-up PR.

            New evidence `kind` values may be added in future versions
            on a strictly additive basis; clients SHOULD treat the
            `kind` enum as forward-compatible (an unknown value MUST
            NOT crash a client).

            Worker-side normalization: a pre-PR-4 Modal that omits
            this field on a reason has its reason coerced to
            `evidence: []` at the Worker boundary so the "always an
            array" contract holds regardless of which side deployed
            first.

    RiskEvidence:
      type: object
      required:
        - kind
        - observedAt
      description: |
        One structured observation supporting a `RiskReason`. Shape is
        a flat tagged-union: every row has `kind` + `observedAt` plus
        kind-specific optional fields. Clients dispatch on `kind` and
        read only the fields documented for that kind.

        **`observedAt` is the scorer-run timestamp** — the moment the
        analysis was performed by the engine that produced THIS
        response. It is **NOT** the original source publication
        timestamp (e.g. when OFAC added an address to its list, or
        when chainabuse received a report). True source-add timestamps
        are not tracked in this version.

        Evidence rows are derived from observations already collected
        for the response (the floor's `supportingFlags` subset, the
        OSINT result block, the internal address label catalog). No
        new fetches are performed to build this list.

        Phase 1 emits at most the first three of the four locked
        kinds. `counterparty_interaction` is reserved in the schema
        but not emitted in Phase 1.
      properties:
        kind:
          type: string
          enum:
            - osint_match
            - internal_label
            - pattern_predicate
            - counterparty_interaction
          description: |
            Locked enum. Additions are a public-contract change and
            require explicit review.

            * `osint_match` — the address is on a public OSINT list
              (sanctions, scam DBs, phishing DBs, chainabuse community
              reports). Fields: `source`, `matchedList`, `label`,
              `detail?`, and for chainabuse rows the optional pair
              `chainabuseCategories` + `chainabuseReportCount`.

            * `internal_label` — the address has a hit in the
              internal address-label catalog. Fields: `source`
              (always `internal`), `label`.

            * `pattern_predicate` — an analysis predicate fired on
              the observed activity (e.g. `transit_wallet`,
              `perm_hijack_detected`). Fields: `predicate` (stable
              slug), `label` (human-readable), `flagText`
              (the raw `flags` entry that triggered it; may be null
              for synthesized meta-predicates like
              `high_flag_density`), `detail?`.

            * `counterparty_interaction` — RESERVED for Phase 2. Will
              carry `address`, `label`, `interactionCount`,
              `lastInteractionAt`, `valueUsd`. Phase 1 never emits
              this kind.
        observedAt:
          type: string
          format: date-time
          description: |
            Scorer-run timestamp (ISO-8601 with timezone offset).
            Shared across every evidence row in a single response.
            NOT a source publication or label-added timestamp.
        source:
          type: string
          description: |
            Origin of the evidence. For `osint_match`: the OSINT
            source identifier (e.g. `chainabuse`, `ofac`, `osint`).
            For `internal_label`: always `internal`. Omitted on
            `pattern_predicate`.
        matchedList:
          type: string
          description: |
            For `osint_match` rows: the specific list within the
            source (e.g. `chainabuse_reports`, `scam_list`,
            `phishing_list`). Omitted on other kinds.
        label:
          type: string
          description: |
            Human-readable label for the evidence:
            * `osint_match` — the label string from the OSINT source
              (e.g. the scam label, or a fallback like "Known
              phishing address" when the source did not provide one).
            * `internal_label` — the internal catalog entry text.
            * `pattern_predicate` — short human-readable label for
              the predicate (e.g. "Transit wallet pattern").
            * `counterparty_interaction` — the counterparty's label
              (Phase 2).
        detail:
          type: string
          nullable: true
          description: |
            Optional free-form detail string for any kind. Currently
            used by `pattern_predicate` for the `high_flag_density`
            meta-predicate (e.g. `"12 flags fired"`). Null when not
            applicable.
        chainabuseCategories:
          type: array
          items: { type: string }
          description: |
            For chainabuse `osint_match` rows: the categories
            (e.g. `["scam", "phishing"]`) reported by the community.
            Omitted on other rows.
        chainabuseReportCount:
          type: integer
          minimum: 0
          description: |
            For chainabuse `osint_match` rows: the total reported
            count. Omitted on other rows.
        predicate:
          type: string
          description: |
            For `pattern_predicate` rows: the stable predicate slug
            (e.g. `transit_wallet`, `perm_hijack_detected`,
            `sanctioned_self_label`, `high_flag_density`). Unknown
            future flag patterns may surface as `unknown_predicate`
            so the invariant "floor with supportingFlags > 0 ⇒
            evidence > 0" holds without a Modal redeploy. Omitted on
            other kinds.
        flagText:
          type: string
          nullable: true
          description: |
            For `pattern_predicate` rows: the raw `flags` entry that
            triggered the predicate. Null for synthesized meta-
            predicates (e.g. `high_flag_density`). Omitted on other
            kinds.
        address:
          type: string
          description: |
            RESERVED for `counterparty_interaction` (Phase 2). The
            counterparty address.
        interactionCount:
          type: integer
          minimum: 1
          description: |
            RESERVED for `counterparty_interaction` (Phase 2).
        lastInteractionAt:
          type: string
          format: date-time
          nullable: true
          description: |
            RESERVED for `counterparty_interaction` (Phase 2).
        valueUsd:
          type: number
          nullable: true
          description: |
            RESERVED for `counterparty_interaction` (Phase 2).

    Honeypot:
      type: object
      required: [status]
      description: |
        Honeypot verification result on `POST /api/v1/token/check` (and
        the streaming variant). The `status` field indicates whether the
        verification was actionable. **Read `status` BEFORE relying on
        any measurable field.**

        `isHoneypot` is `null` for all non-ok statuses. Treating
        `isHoneypot === null` as `false` ("not a honeypot") is incorrect;
        null means "unknown".

        Saving a non-ok result via `POST /api/v1/token/reports` is **not
        charged** — see the cost summary at the top of this spec and the
        CHANGELOG entry for `honeypot.status`.

        Reason codes (when `status != "ok"`):
        - `provider_rate_limited` — upstream provider rate-limited the
          honeypot detector.
        - `provider_error` — upstream provider error (network, timeout,
          5xx, etc.).
        - `analyzer_error` — internal analyzer exception.
        - `network_not_supported` — detector is unavailable for this
          network/setup.
        - `no_data` — no on-chain DEX pair found or token contract
          inconclusive.
      properties:
        status:
          type: string
          enum: [ok, unable_to_verify, not_supported, no_data]
        reason:
          type: string
          nullable: true
          enum:
            - provider_rate_limited
            - provider_error
            - analyzer_error
            - network_not_supported
            - no_data
        isHoneypot:
          type: boolean
          nullable: true
          description: |
            True/false ONLY when `status="ok"`. Null for all non-ok statuses.
            Null means "unknown" — NOT "not a honeypot".
        buyTax: { type: number, nullable: true }
        sellTax: { type: number, nullable: true }
        buySuccess: { type: boolean, nullable: true }
        sellSuccess: { type: boolean, nullable: true }
        transferPausable: { type: boolean, nullable: true }
        hasBlacklist: { type: boolean, nullable: true }
        hasMaxTx: { type: boolean, nullable: true }
        hasCooldown: { type: boolean, nullable: true }
        liquidityLocked: { type: boolean, nullable: true }
        liquidityAmount: { type: number, nullable: true }
        pairAddress: { type: string, nullable: true }
        dexName: { type: string, nullable: true }
        flags:
          type: array
          items: { type: string }
          nullable: true
        riskLevel:
          type: string
          nullable: true

    FlagDetail:
      type: object
      required: [code, templateKey, defaultEn]
      description: |
        Localization-friendly flag metadata. Use `code` / `templateKey` for
        translation/mapping. `defaultEn` is a display fallback only — it
        may include dynamic values and is not stable across releases.
        Pattern flags use the `pattern.*` namespace; bot flags (under
        `timing.botFlagDetails`) use the `bot.*` namespace.
      properties:
        code:
          type: string
          description: Stable machine-readable identifier. Use this for translation/mapping.
          example: bulk_collection
        templateKey:
          type: string
          description: Namespaced i18n key. `pattern.<code>` for pattern flags, `bot.<code>` for bot flags.
          example: pattern.bulk_collection
        defaultEn:
          type: string
          description: Current English display fallback. May include dynamic values. Do not use as translation key.
          example: "BULK COLLECTION pattern: 24 inbound TXs in 600s"
        severity:
          type: [string, 'null']
          enum: [critical, high, medium, low, null]
          example: medium
        description:
          type: [string, 'null']
          description: Short stable English explanation of the flag meaning.
          example: "Many sources sent funds within a short window (bulk collection)."

    AddressConnection:
      type: object
      description: Aggregated interaction volume for a single counterparty category
      required: [category, percentage, valueFormatted, valueRaw, unit]
      properties:
        category:
          $ref: '#/components/schemas/CategoryEnum'
        percentage:
          type: number
          description: "Percentage of total interaction volume (0.0–100.0)"
          example: 64.0
        valueFormatted:
          type: string
          description: "Human-readable value with network unit (display-only)"
          example: "15,000 TRX"
        valueRaw:
          type: string
          description: "Raw value in network base units (wei, sun, satoshi, etc.) for programmatic use"
          example: "15000000000"
        unit:
          type: string
          description: "Base unit name for the network (wei, sun, satoshi, lovelace, etc.)"
          example: "sun"

    GraphSnapshot:
      type: object
      description: Graph snapshot metadata (no graphData payload)
      required: [id, reportId, name, nodeCount, edgeCount, createdAt, updatedAt]
      properties:
        id: { type: integer, example: 42 }
        reportId: { type: string, description: "Report public_id" }
        name: { type: string, maxLength: 100, example: "After expanding counterparties" }
        description: { type: string, nullable: true, maxLength: 500 }
        nodeCount: { type: integer, description: "Computed server-side from graphData.nodes" }
        edgeCount: { type: integer, description: "Computed server-side from graphData.edges" }
        createdAt: { type: integer, description: "Unix timestamp (seconds)" }
        updatedAt: { type: integer, description: "Unix timestamp (seconds)" }

    GraphSnapshotFull:
      allOf:
        - $ref: '#/components/schemas/GraphSnapshot'
        - type: object
          required: [graphData]
          properties:
            graphData:
              type: object
              required: [nodes, edges]
              properties:
                nodes: { type: array, items: { type: object } }
                edges: { type: array, items: { type: object } }
