openapi: 3.1.0

info:
  title: Triport — Wallet Identity REST
  version: 0.1.0-n8-wid-1
  description: |
    Cross-chain wallet profile for an EVM address: on-chain names (ENS,
    Basenames, Unstoppable Domains, SPACE ID), native balance / nonce /
    contract-or-wallet per mounted network, deployer detection with the
    list of created tokens, and Triport's own labels of known addresses.

    One request, one JSON, every EVM network mounted on the serving PoP
    plus Tron (address derived from the same key). Networks that are not
    mounted or did not answer are reported per chain in `chains.<net>.error`;
    the response is still `200` unless every requested chain failed.

    Names are counted only when the on-chain record points back at the
    queried address (`verified: true`). Off-chain names (CCIP-read, e.g.
    `*.cb.id`) are reported in `unresolved` with `reason: offchain_lookup`.

    Available from the **Business** tier (`wallet_identity` category).
    Solana wallet REST (`/v1/wallet/{base58}/…`) is a separate surface and
    keeps its own contract.

x-tier-required: business
x-scope: "*"
x-availability: supported
x-wire-enabled: true

servers:
  - url: https://triport.io/v1/wallet
    description: Production (EU + US PoPs, active-active)

security:
  - BearerAuth: []
  - ApiKeyHeader: []
  - ApiKeyQuery: []

tags:
  - name: wallet-identity
    description: Cross-chain wallet profile by EVM address.
  - name: wallet-watch
    description: 'Event subscriptions (deployments, bridges, fresh funding) with profile enrichment and filters, and the `wallet_activity` address-activity stream.'

paths:
  /{address}/research:
    get:
      tags: [wallet-identity]
      operationId: walletResearch
      summary: Research EVM wallet activity and connections
      description: |
        Period transaction counts, per-asset volumes, counterparties and labels,
        bridge/exchange flows, approvals and shared observed funding candidates.
        Check per-chain coverage; first observed activity is not a creation date.
        Internal transfers, NFT transfers and Permit2 are excluded. Amounts are
        raw decimal strings. Cluster membership is a heuristic, not ownership.
        No historical index is stored. Each response scans a bounded block window.
        Follow next_cursor for older blocks; totals describe this window only.
      x-tier-required: business
      x-tier-category: wallet_identity
      parameters:
        - in: path
          name: address
          required: true
          schema: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
        - in: query
          name: chains
          description: Comma-separated eth,base,polygon,bsc,robinhood; default all five.
          schema: {type: string}
        - in: query
          name: from
          description: Inclusive RFC3339 start; default 30 days before to. Use depth=genesis instead for lifetime traversal.
          schema: {type: string, format: date-time}
        - in: query
          name: to
          description: Exclusive RFC3339 end; default current time.
          schema: {type: string, format: date-time}
        - in: query
          name: limit
          schema: {type: integer, minimum: 1, maximum: 100, default: 50}
        - in: query
          name: offset
          schema: {type: integer, minimum: 0, maximum: 10000, default: 0}
        - in: query
          name: sort
          description: Amount order groups by asset before ranking raw amounts within that asset.
          schema: {type: string, enum: [time, amount], default: time}
        - in: query
          name: depth
          description: Traverse back to genesis; cannot be combined with from.
          schema: {type: string, enum: [genesis]}
        - in: query
          name: cursor
          description: Opaque continuation. Keep address, chains, from, to and scan_blocks from the previous response.
          schema: {type: string, maxLength: 8192}
        - in: query
          name: scan_blocks
          description: Maximum blocks read per network in this request; older blocks use next_cursor.
          schema: {type: integer, minimum: 1, maximum: 128, default: 16}
      responses:
        '200':
          description: Wallet research results; coverage and errors are reported per chain.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletResearch'}
        '400': {description: 'Invalid query, period, network or pagination.'}
        '401': {description: Authentication required.}
        '403': {description: Business or Enterprise tier required.}
        '429':
          description: Tier budget or concurrency limit exceeded.
          headers:
            Retry-After: {schema: {type: string}, description: Retry delay in seconds.}
        '503': {description: Live research is disabled or unavailable.}
  /{address}/identity:
    get:
      tags: [wallet-identity]
      summary: Get wallet identity profile
      description: |
        Returns the cross-chain profile of an EVM address in one call:
        verified on-chain names, per-network native balance / nonce /
        contract flag, activity summary, known-address labels and — on
        request — deployer detection with created tokens.

        Networks are queried in parallel with a per-network timeout; a
        network that fails is reported in `chains.<net>.error` while the
        rest of the profile is returned. Every internal upstream call is
        charged against the key's `<net>_read_rpc` budget (BSC `eth_call`
        → `bsc_read_rpc_heavy`) in addition to one `wallet_identity` token
        (two with `fresh=1`).

        Socials (`fields=socials`, off by default, implies `names`): social
        handles, website, avatar and header are read from the
        text records of the address's verified names and returned in
        `socials[]`, with the raw records in `names[].records`. Name records
        are on-chain, so Robinhood and Tron get socials through the address's
        name in ENS, Basenames, SPACE ID or Unstoppable Domains. Entries from
        name records carry `verified_owner: false`: the record is claimed by
        the name owner, the account itself is not checked. Reading records
        costs one extra `<net>_read_rpc` per name service where a name was
        found.

        External sources — Farcaster (`source: farcaster`, `chain: optimism`)
        and Lens (`source: lens`, `chain: lens`): the address's accounts in
        these protocols are returned in `names[]` (custody / verified address,
        account owner → `verified: true`) and their profile links in
        `socials[]`. They are queried over HTTP, not through the key's network
        budgets, and are charged as `social_read` in `meta.cost` (one per
        external source per address on a cache miss). An external source may
        answer partially or not at all; the response is still `200` with what
        was found, and the source is listed in `unresolved[]` with
        `reason: upstream_error`, `timeout` or `budget`. External sources are
        enabled per PoP; one named in `name_sources` but not enabled is
        reported with `reason: not_available`.
      operationId: walletIdentity
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      x-sentinel-id: wallet.identity
      x-sentinel-expect:
        http_status: [200]
        result_shape: object
        result_non_empty: true
      parameters:
        - in: path
          name: address
          required: true
          description: EVM address, `0x` + 40 hex characters (any letter case).
          schema:
            type: string
            pattern: '^0x[0-9a-fA-F]{40}$'
          example: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
        - in: query
          name: chains
          required: false
          description: |
            Comma-separated subset of `eth,base,polygon,bsc,robinhood,tron`.
            Default: every known network. An unknown id → `400 unknown_chain`;
            a known network that is not mounted on the serving PoP →
            `chains.<net>.error: "not_mounted"`.

            `chains` selects the networks for `balances`, `activity` and
            `deployer` only. It does **not** restrict `names`: name services
            are always read on their own network (ENS and Unstoppable on eth,
            Basenames on base, SPACE ID on bsc, Unstoppable on polygon), so
            `chains=robinhood` still returns the address's ENS name. Use
            `name_sources` to narrow the name services.
          schema: {type: string}
          example: 'eth,base,tron'
        - in: query
          name: name_sources
          required: false
          description: |
            Comma-separated subset of
            `ens,basenames,spaceid,unstoppable,farcaster,lens` — which name
            services to query when `names` is requested. Default: all of them,
            independent of `chains`. Each on-chain service is charged to the
            key's `<net>_read_rpc` budget of the service's own network (`eth` /
            `base` / `polygon`; SPACE ID → `bsc_read_rpc_heavy`); the external
            `farcaster` and `lens` are charged as `social_read`. Cached results
            are free. A service whose
            network is not mounted on the serving PoP is reported in
            `unresolved[]` with `reason: not_mounted`. An external source
            (`farcaster`, `lens`) named explicitly but not enabled on the
            serving PoP is reported in `unresolved[]` with
            `reason: not_available`; with the default (all services) a
            disabled external source is skipped silently. An unknown id →
            `400 invalid_name_sources`.
          schema: {type: string}
          example: 'ens,basenames'
        - in: query
          name: fields
          required: false
          description: |
            Comma-separated projection of `names,socials,balances,activity,labels,deployer`.
            Default: `names,balances,activity,labels`. `deployer` is the
            expensive group (CREATE-address scan) and is off by default.
            `socials` is off by default too and implies `names`: it adds
            `socials[]` and `names[].records` (one extra `<net>_read_rpc` per
            name service where a name was found).
          schema: {type: string}
          example: 'names,balances,deployer'
        - in: query
          name: deployer_max_nonce
          required: false
          description: |
            Upper bound of the nonce range scanned for created contracts
            (`deployer.truncated: true` when the address's nonce exceeds it).
            Default 2000, operator ceiling 5000.
          schema: {type: integer, minimum: 0, maximum: 5000, default: 2000}
        - in: query
          name: fresh
          required: false
          description: Bypass the profile cache (`1`/`true`). Costs a second `wallet_identity` token.
          schema: {type: string, enum: ['0', '1', 'true', 'false']}
      responses:
        '200':
          description: Profile (possibly partial — see `chains.<net>.error` and `unresolved`).
          headers:
            X-RateLimit-Limit: {$ref: '_shared.yaml#/components/headers/XRateLimitLimit'}
            X-RateLimit-Remaining: {$ref: '_shared.yaml#/components/headers/XRateLimitRemaining'}
            X-RateLimit-Category: {$ref: '_shared.yaml#/components/headers/XRateLimitCategory'}
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletProfile'}
              examples:
                default:
                  summary: Default fields
                  value:
                    address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
                    address_tron: 'TVpPjpEyXeUD4BxJmUqrMXSf1mwbktL8ac'
                    names:
                      - {source: ens, name: vitalik.eth, chain: eth, primary: true, verified: true, normalized: true}
                    labels: []
                    chains:
                      eth: {balance_wei: '1234500000000000000', balance: '1.2345', nonce: 812, is_contract: false, latency_ms: 250}
                      base: {balance_wei: '40000000000000000', balance: '0.04', nonce: 95, is_contract: false, latency_ms: 250}
                      polygon: {balance_wei: '12500000000000000000', balance: '12.50', nonce: 40, is_contract: false, latency_ms: 250}
                      bsc: {balance_wei: '0', balance: '0.00', nonce: 0, is_contract: false, latency_ms: 250}
                      robinhood: {balance_wei: '0', balance: '0.00', nonce: 0, is_contract: false, latency_ms: 250}
                      tron: {balance_sun: '0', balance: '0.00', nonce: null, is_contract: false, latency_ms: 250}
                    activity: {any_tx: true, chains_active: [eth, base, polygon], max_nonce: 812}
                    unresolved: []
                    meta:
                      cached: false
                      generated_at: '2026-09-14T21:04:11Z'
                      pop: eu
                      cost: {wallet_identity: 1, eth_read_rpc: 4, base_read_rpc: 3, polygon_read_rpc: 3, bsc_read_rpc: 3, robinhood_read_rpc: 3, tron_read_rpc: 3}
                socials:
                  summary: Socials (fields=socials&chains=robinhood)
                  value:
                    address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
                    names:
                      - source: ens
                        name: vitalik.eth
                        chain: eth
                        primary: true
                        verified: true
                        normalized: true
                        records: {com.twitter: VitalikButerin, com.github: vbuterin, url: 'https://vitalik.eth.limo', email: 'owner@example.com'}
                    socials:
                      - {platform: twitter, handle: VitalikButerin, url: 'https://x.com/VitalikButerin', source: ens, name: vitalik.eth, chain: eth, key: com.twitter, verified_owner: false}
                      - {platform: github, handle: vbuterin, url: 'https://github.com/vbuterin', source: ens, name: vitalik.eth, chain: eth, key: com.github, verified_owner: false}
                      - {platform: website, handle: '', url: 'https://vitalik.eth.limo', source: ens, name: vitalik.eth, chain: eth, key: url, verified_owner: false}
                    unresolved: []
                    meta:
                      cached: false
                      generated_at: '2026-09-22T10:00:00Z'
                      pop: eu
                      cost: {wallet_identity: 1, eth_read_rpc: 2}
        '400':
          description: invalid_address | unknown_chain | invalid_fields | invalid_name_sources | invalid_deployer_max_nonce
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '403': {$ref: '_shared.yaml#/components/responses/Forbidden'}
        '429': {$ref: '_shared.yaml#/components/responses/RateLimited'}
        '503':
          description: all_chains_unavailable — no requested network answered.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}

  /identity/batch:
    post:
      tags: [wallet-identity]
      summary: Batch wallet identity with filters
      description: |
        Profiles up to 100 addresses (Business) or 500 (Enterprise) in one
        call, optionally filtered by a JSON predicate tree (`all` / `any` /
        `not` over `has_name`, `name_matches`, `is_deployer`, `tokens_created`,
        `deployed_symbol`, `balance`, `balance_any`, `nonce`, `active_on`,
        `is_contract`, `has_label`, `has_social`). `return: matched` (default) returns only
        addresses that pass the filter; `return: all` returns every profile
        with a `matched` flag.

        Cheap predicates are evaluated first; the expensive `deployer` group
        is computed only for addresses the cheap predicates did not reject.
        Duplicate addresses are collapsed (`meta.deduplicated`), invalid ones
        are reported in `errors[]` without failing the batch.

        Cost: `⌈addresses × chains / 10⌉` `wallet_identity` tokens (×2 with
        `fresh`) plus the per-network `<net>_read_rpc` charges of every
        profile, summed in `meta.cost`.
      operationId: walletIdentityBatch
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      x-sentinel-id: wallet.identity.batch
      x-sentinel-body:
        addresses: ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '0x0000000000000000000000000000000000000001']
        chains: [eth]
        fields: [balances, activity]
        return: all
      x-sentinel-expect:
        http_status: [200]
        result_shape: object
        result_non_empty: true
        require_fields: [results, matched, total, errors, meta]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WalletBatchRequest'}
            example:
              addresses: ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', '0x0000000000000000000000000000000000000001']
              chains: [eth, base]
              fields: [names, balances, deployer]
              filter:
                all:
                  - {has_name: true}
                  - {is_deployer: true}
                  - {tokens_created: {gte: 1}}
                  - {balance: {chain: eth, gte: '0.3'}}
              return: matched
      responses:
        '200':
          description: Batch result (per-address errors inside `errors[]`).
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletBatchResponse'}
              example:
                results:
                  - address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
                    matched: true
                    names:
                      - source: ens
                        name: vitalik.eth
                        chain: eth
                        primary: true
                        verified: true
                        normalized: true
                        records: {com.twitter: VitalikButerin, com.github: vbuterin, url: 'https://vitalik.eth.limo', email: 'owner@example.com'}
                    socials:
                      - {platform: twitter, handle: VitalikButerin, url: 'https://x.com/VitalikButerin', source: ens, name: vitalik.eth, chain: eth, key: com.twitter, verified_owner: false}
                      - {platform: github, handle: vbuterin, url: 'https://github.com/vbuterin', source: ens, name: vitalik.eth, chain: eth, key: com.github, verified_owner: false}
                      - {platform: website, handle: '', url: 'https://vitalik.eth.limo', source: ens, name: vitalik.eth, chain: eth, key: url, verified_owner: false}
                    unresolved: []
                    meta:
                      cached: false
                      generated_at: '2026-09-22T10:00:00Z'
                      pop: eu
                      cost: {wallet_identity: 1, eth_read_rpc: 2}
                matched: 1
                total: 2
                errors: []
                meta:
                  generated_at: '2026-09-22T10:00:00Z'
                  pop: eu
                  cost: {wallet_identity: 1, eth_read_rpc: 3}
                  chains: [robinhood]
                  filter_fields: 'names,socials'
                  deduplicated: 0
        '400':
          description: invalid_body | invalid_addresses | too_many_addresses | batch_too_large (cost ⌈addresses×chains/10⌉ exceeds the tier burst capacity rps×2 — split the batch) | unknown_chain | invalid_fields | invalid_name_sources | invalid_filter | invalid_return | invalid_deployer_max_nonce
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '403': {$ref: '_shared.yaml#/components/responses/Forbidden'}
        '413':
          description: body_too_large — request body exceeds 1 MiB.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '429': {$ref: '_shared.yaml#/components/responses/RateLimited'}

  /watch:
    post:
      tags: [wallet-watch]
      summary: Create a wallet-watch subscription
      description: |
        Subscribes to on-chain events on the selected networks: contract and
        smart-wallet deployments, bridge deposits/fills, fresh-wallet funding,
        EIP-7702 delegations. Each event's participants can be enriched with
        the identity profile (`enrich`) and matched against a
        [filter](#/components/schemas/Filter) (`fresh_wallet` is allowed
        here). Delivery: `webhook` (HMAC-SHA256 `X-Triport-Signature`, 3
        retries, dead-letter), `ws` (`/ws/wallet-watch?id=…`) or `pull`
        (REST cursor). Subscriptions belong to the account behind the API key.

        `wallet_activity` (address-activity stream): one event per (network,
        transaction, watched address) for every transaction in which a
        `watchlist` address takes part — tx sender/target, native, internal
        native (where traced), ERC-20/721/1155 movements, approvals, the
        address in any log, bridge deposits and fills. It requires a non-empty
        `watchlist` (`400 watchlist_required`); `filter`/`enrich` do not
        apply to it (only-`wallet_activity` + filter → `400
        filter_not_supported`). Payload: `WalletActivityEvent`. Guide:
        `docs/api/guide/platform/wallet-activity.md`.

        Tier limits — Business: 5 subscriptions, 5 networks each, watchlist
        ≤ 10 000 addresses, 50 `wallet_activity` events/s per subscription,
        webhook 10 rps; Enterprise: 50 subscriptions, 30 networks, watchlist
        ≤ 100 000, 500 `wallet_activity` events/s. Request body ≤ 1 MiB.
      operationId: walletWatchCreate
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      x-sentinel-id: wallet.watch.create
      x-sentinel-body: {name: sentinel, chains: [eth], events: [contract_deploy], delivery: {type: pull}}
      x-sentinel-expect:
        # Loopback monitor token has no account → the route answers 401 account_required (proves the surface is mounted).
        http_status: [401]
        result_shape: object
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WalletWatchRequest'}
            example:
              name: bridges-in-named
              chains: [eth, base, robinhood]
              events: [bridge_in, bridge_out, contract_deploy]
              filter: {all: [{has_name: true}, {balance_any: {gte: '0.1'}}]}
              enrich: [names, balances]
              min_amount_usd: 1000
              delivery: {type: webhook, url: 'https://example.com/hooks/triport', secret: 'change-me'}
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletWatchSubscription'}
        '400':
          description: 'invalid_body | invalid_name | invalid_chains | unknown_chain | too_many_chains | invalid_events | unknown_event | invalid_filter | invalid_enrich | invalid_watchlist | too_many_watchlist | invalid_min_amount_usd | invalid_delivery | watchlist_required | filter_not_supported | invalid_min_confirmations'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401':
          description: account_required — the API key is not backed by an account (yaml/loopback token), or credentials are missing.
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '403':
          description: tier_insufficient | subscription_limit
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '429': {$ref: '_shared.yaml#/components/responses/RateLimited'}
        '503':
          description: 'capacity_exceeded (the total watched-address cap would be exceeded) | busy (another watched-address change is being applied; retry) | option_unavailable (`min_confirmations > 0` not supported by this deployment''s database yet; refused, never dropped)'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
    get:
      tags: [wallet-watch]
      summary: List wallet-watch subscriptions
      description: 'Subscriptions of the calling account with the tier limits in effect, the key''s tier and where the limits come from.'
      operationId: walletWatchList
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      x-sentinel-id: wallet.watch.list
      x-sentinel-expect:
        http_status: [401]
        result_shape: object
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [subscriptions, total, limits]
                properties:
                  subscriptions:
                    type: array
                    items: {$ref: '#/components/schemas/WalletWatchSubscription'}
                  total: {type: integer}
                  limits: {$ref: '#/components/schemas/WalletWatchLimits'}
                  tier: {type: string, description: 'Tier id of the calling key (e.g. `business`, `enterprise`); empty when no tier is on the request context.'}
                  limits_source:
                    type: string
                    enum: [tier, override, default]
                    description: '`tier` = limits of the key''s plan; `override` = limits raised for the account by support; `default` = no tier on the context, Business limits apply.'
        '401':
          description: account_required
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '403': {$ref: '_shared.yaml#/components/responses/Forbidden'}
        '429': {$ref: '_shared.yaml#/components/responses/RateLimited'}

  /watch/coverage:
    get:
      tags: [wallet-watch]
      summary: Monitoring coverage of this PoP
      description: |
        What the answering PoP monitors per network for `wallet_activity`:
        coverage levels (`logs`, `internal_native`), confirmation depth at
        emit and reorg window, the scanner's live state and lag, the event
        store's retention window, and a fixed list of known limitations. Not
        tied to a subscription.
      operationId: walletWatchCoverage
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletWatchCoverage'}
        '401':
          description: 'account_required'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '403': {$ref: '_shared.yaml#/components/responses/Forbidden'}
        '503':
          description: 'coverage_unavailable — not wired on this PoP.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}

  /watch/{id}:
    get:
      tags: [wallet-watch]
      summary: Get a subscription
      description: One subscription of the calling account (webhook secret is masked).
      operationId: walletWatchGet
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletWatchSubscription'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '403': {$ref: '_shared.yaml#/components/responses/Forbidden'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}
    patch:
      tags: [wallet-watch]
      summary: Update a subscription
      description: "Partial update — any field of the create body; `enabled: false` pauses delivery without deleting history."
      operationId: walletWatchUpdate
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
      x-sentinel-body: {enabled: false}
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WalletWatchRequest'}
            example: {enabled: false}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletWatchSubscription'}
        '400':
          description: validation error (same codes as create)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '503':
          description: 'capacity_exceeded | busy | option_unavailable (same as create)'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}
    delete:
      tags: [wallet-watch]
      summary: Delete a subscription
      description: Removes the subscription; stored events stay until retention expires.
      operationId: walletWatchDelete
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
      x-sentinel-body: {}
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      responses:
        '204': {description: Deleted.}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}

  /watch/{id}/events:
    get:
      tags: [wallet-watch]
      summary: Pull events (REST cursor)
      description: |
        Events matched for this subscription after the given position, oldest
        first. Works for every delivery type (webhook/ws subscribers can use
        it to reconcile). Every event carries `event_id` (stable across PoPs)
        and `cursor`.

        Resume with `cursor` (preferred; takes precedence over `after`). On
        the PoP that issued it, the page continues exactly by seq
        (`mode: seq`). On the other PoP it continues by block numbers
        (`mode: blocks`), and up to about one block per network can repeat —
        deduplicate by `event_id`. `after` is the PoP-local seq (deprecated).

        Events older than the per-PoP retention (72 h / 5 GiB store) are
        gone: a cursor or `after` older than that → `410 cursor_expired` with
        `oldest_cursor` and `recoverable: false`.
      operationId: walletWatchEvents
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
        - in: query
          name: after
          schema: {type: integer, minimum: 0, default: 0}
        - in: query
          name: cursor
          required: false
          description: 'The `cursor` of the last event processed (opaque, `c1.…`, bound to this subscription). Takes precedence over `after`. Malformed or longer than 4096 characters → `400 invalid_cursor`; issued for another subscription → `400 cursor_subscription_mismatch`.'
          schema: {type: string, maxLength: 4096}
        - in: query
          name: limit
          schema: {type: integer, minimum: 1, maximum: 1000, default: 100}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [events, next_after, next_cursor, mode, has_more, latest_seq, oldest_seq]
                properties:
                  events:
                    type: array
                    items:
                      anyOf:
                        - {$ref: '#/components/schemas/WalletActivityEvent'}
                        - {$ref: '#/components/schemas/WalletWatchGap'}
                        - {$ref: '#/components/schemas/WalletWatchEvent'}
                  next_after: {type: integer, description: 'Deprecated: pass as `after` on the next call (PoP-local seq).'}
                  next_cursor: {type: string, description: 'Pass as `cursor` on the next call; unchanged when the page is empty.'}
                  mode: {type: string, enum: [seq, blocks], description: 'How this page was resolved (`blocks` = cursor from the other PoP).'}
                  has_more: {type: boolean}
                  latest_seq: {type: integer}
                  oldest_seq: {type: integer}
                  generated_at: {type: string, format: date-time}
        '400':
          description: 'invalid_after | invalid_limit | invalid_cursor | cursor_subscription_mismatch'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}
        '410':
          description: 'cursor_expired — the cursor (or `after`) is older than this PoP retains; the events in between cannot be recovered.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletWatchCursorExpired'}
        '503':
          description: 'events_unavailable — the event store is not enabled on this PoP.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}

  /watch/{id}/watchlist:
    get:
      tags: [wallet-watch]
      summary: Page the watchlist
      description: 'The subscription''s watchlist in address order (keyset pagination, stable under concurrent changes).'
      operationId: walletWatchWatchlistGet
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
        - in: query
          name: cursor
          required: false
          description: '`next_cursor` of the previous page (the last address of that page).'
          schema: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
        - in: query
          name: limit
          schema: {type: integer, minimum: 1, maximum: 5000, default: 1000}
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                required: [watchlist, total, has_more, next_cursor]
                properties:
                  watchlist:
                    type: array
                    items: {type: string}
                  total: {type: integer}
                  has_more: {type: boolean}
                  next_cursor: {type: string, description: 'Last address of this page; empty when `has_more` is false.'}
        '400':
          description: 'invalid_cursor | invalid_limit'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}
    post:
      tags: [wallet-watch]
      summary: Add or remove watchlist addresses
      description: |
        Changes the watchlist without re-creating the subscription. Up to
        10 000 addresses per call (`add` + `remove` together), body ≤ 1 MiB.
        Applied atomically: concurrent calls and a concurrent `PATCH` never
        lose each other's addresses; a call that changes nothing writes
        nothing. The PoP that accepted the call applies it at once, the other
        PoP within about 5 seconds. New addresses are watched from that moment
        on — no history backfill.
      operationId: walletWatchWatchlistModify
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                add:
                  type: array
                  items: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
                remove:
                  type: array
                  items: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
            example: {add: ['0x023ab8e20a4682d315daef4c91db96bd77934d66'], remove: ['0x133243d447026345c2b368d7ffe435dbe3c566eb']}
      responses:
        '200':
          description: 'OK (also for a no-op call).'
          content:
            application/json:
              schema:
                type: object
                required: [added, removed, total]
                properties:
                  added: {type: integer}
                  removed: {type: integer}
                  total: {type: integer, description: 'Watchlist size after the call.'}
        '400':
          description: 'invalid_body | too_many_addresses (> 10 000 per call) | invalid_watchlist | conflicting_address (same address in add and remove) | too_many_watchlist (tier limit) | watchlist_required (would empty the watchlist of a `wallet_activity` subscription)'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}
        '503':
          description: 'capacity_exceeded — the total watched-address cap would be exceeded; nothing was changed | busy — another watched-address change is being applied; retry.'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/WalletRequestError'}

  /watch/{id}/dead-letters:
    get:
      tags: [wallet-watch]
      summary: Failed webhook deliveries
      description: Last 100 webhook deliveries that exhausted retries or were rate-limited, with the event `seq` to re-pull via the cursor.
      operationId: walletWatchDeadLetters
      parameters:
        - in: path
          name: id
          required: true
          schema: {type: string, format: uuid}
      x-tier-required: business
      x-tier-category: wallet_identity
      x-rate-limit-rps: {business: 20, enterprise: 100}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [dead_letters]
                properties:
                  dead_letters:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: integer}
                        sub_id: {type: string}
                        seq: {type: integer}
                        url: {type: string}
                        reason: {type: string}
                        failed_at: {type: string, format: date-time}
        '401': {$ref: '_shared.yaml#/components/responses/Unauthorized'}
        '404': {$ref: '_shared.yaml#/components/responses/NotFound'}

components:
  securitySchemes:
    BearerAuth: {$ref: '_shared.yaml#/components/securitySchemes/BearerAuth'}
    ApiKeyHeader: {$ref: '_shared.yaml#/components/securitySchemes/ApiKeyHeader'}
    ApiKeyQuery: {$ref: '_shared.yaml#/components/securitySchemes/ApiKeyQuery'}

  schemas:
    WalletRequestError:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
          description: Stable machine code (`invalid_address`, `unknown_chain`, `invalid_fields`, `invalid_name_sources`, `invalid_deployer_max_nonce`, `all_chains_unavailable`, `wallet_identity_disabled`).
        message: {type: string}
        code: {type: integer, description: HTTP status mirrored in the body.}

    WalletName:
      type: object
      required: [source, name, chain, primary, verified, normalized]
      properties:
        source:
          type: string
          enum: [ens, basenames, unstoppable, spaceid, farcaster, lens]
          description: 'Name service. `farcaster` and `lens` are external sources: the name is the account username (Farcaster fname, Lens `localName`).'
        name: {type: string, example: vitalik.eth}
        chain:
          type: string
          enum: [eth, base, bsc, polygon, optimism, lens]
          description: 'Network the name lives on: `eth` / `base` / `bsc` / `polygon` for on-chain name services, `optimism` for Farcaster (IdRegistry), `lens` for Lens Chain. `optimism` and `lens` are not profile networks and are not accepted in `chains`.'
        primary: {type: boolean, description: The address's reverse record (primary name).}
        verified: {type: boolean, description: Forward record resolves back to the queried address.}
        normalized: {type: boolean, description: Name is already in ENSIP-15 normalized form.}
        records:
          type: object
          additionalProperties: {type: string}
          description: |
            Raw text records of the name, key → value (ENSIP-5 `text` keys such
            as `com.twitter`, `url`, `email`; Unstoppable keys such as
            `social.twitter.username`). Present only with `fields=socials` and
            only for keys that are set. Values are returned as stored on-chain,
            without normalization: this is untrusted input written by the name
            owner — escape it before rendering. `email` is returned only here,
            never in `socials`.
          example: {com.twitter: VitalikButerin, com.github: vbuterin}

    WalletSocial:
      type: object
      required: [platform, handle, url, source, name, chain, key, verified_owner]
      description: |
        One social account or profile field taken from a text record of a
        verified name (`names[].verified: true`) or from the profile of a
        Farcaster / Lens account (`source: farcaster` / `lens`); records of
        unverified names are never turned into socials. Values pass per-platform normalization
        and validation (handle pattern, 256-character cap, `https://` URLs);
        a record that fails stays only in `names[].records`. The same platform
        found under two names (e.g. ENS and Basenames) gives two entries, in
        the order of `names`. `email` records are never turned into socials.
      properties:
        platform:
          type: string
          enum: [twitter, github, telegram, discord, reddit, youtube, website, avatar, header]
        handle: {type: string, description: 'Account handle without `@` or URL prefix; empty string for URL-only platforms (`website`, `avatar`, `header`).'}
        url: {type: string, description: Canonical profile URL (for `website` / `avatar` / `header` — the record URL).}
        source:
          type: string
          enum: [ens, basenames, spaceid, unstoppable, farcaster, lens]
          description: Name service or external account source the entry was read from.
        name: {type: string, description: 'Name or account whose record this is, e.g. `vitalik.eth` or Farcaster `dwr`.'}
        chain:
          type: string
          enum: [eth, base, bsc, polygon, optimism, lens]
          description: Network of the name (`optimism` for Farcaster, `lens` for Lens).
        key: {type: string, description: 'Original record key or profile field, e.g. `com.twitter`, `social.twitter.username`, Lens attribute `x`.'}
        verified_owner:
          type: boolean
          description: |
            Whether the link between the social account and the address was
            confirmed by someone other than the name owner. Always `false` for
            name records (`ens`, `basenames`, `spaceid`, `unstoppable`): the
            record is claimed by the name owner and Triport does not verify it;
            the name itself is forward-verified (`names[].verified`), the
            record is not. `true` is possible only for an external source
            (`farcaster`, `lens`) and only together with `verified_by`, which
            names who confirmed it. Profile strings that nobody confirmed are
            `false`.
        verified_by:
          type: string
          enum: [farcaster_client]
          description: |
            Who confirmed the account link; present only when
            `verified_owner: true`. `farcaster_client` — the Farcaster client
            attested the linked X / GitHub account (OAuth); the Farcaster
            protocol itself does not verify these strings.
      # verified_owner: true ⇔ verified_by present, and only for external sources.
      if:
        required: [verified_owner]
        properties:
          verified_owner: {const: true}
      then:
        required: [verified_by]
        properties:
          source: {enum: [farcaster, lens]}
          verified_by: {type: string}
      else:
        not:
          required: [verified_by]
          properties:
            verified_by: {type: string}

    WalletLabel:
      type: object
      required: [source, entity, category]
      properties:
        source: {type: string, enum: [triport]}
        entity: {type: string, example: Binance}
        category: {type: string, enum: [cex, bridge, solver, dex, mixer, fund, other]}
        subcategory: {type: string}
        chain: {type: string}
        verified: {type: boolean}

    WalletChainState:
      type: object
      properties:
        balance_wei: {type: string, description: 'Native balance in wei (EVM networks), decimal string.'}
        balance_sun: {type: string, description: 'Native balance in sun (Tron), decimal string.'}
        balance: {type: string, description: 'Native balance in coin units, decimal string (at least two fractional digits).'}
        nonce:
          type: [integer, 'null']
          description: Sent-transaction count; `null` on Tron (no nonce in the Tron account model).
        is_contract: {type: boolean, description: Address holds contract code. An EIP-7702 delegation designator is NOT counted as a contract.}
        delegate: {type: string, description: EIP-7702 delegate contract when the EOA has set a delegation (code `0xef0100‖address`).}
        latency_ms: {type: integer}
        error:
          type: string
          description: Present when the network did not answer — `not_mounted`, `timeout`, `upstream_error`, `budget`, `rpc_error`.

    WalletDeployedContract:
      type: object
      required: [chain, address, nonce, is_erc20]
      properties:
        chain: {type: string}
        address: {type: string}
        nonce: {type: integer, description: Sender nonce that created the contract.}
        is_erc20: {type: boolean}
        symbol: {type: string}
        name: {type: string}
        decimals: {type: integer}
        total_supply: {type: string}

    WalletDeployer:
      type: object
      required: [is_deployer, scanned_nonces, truncated, contracts, tokens_created]
      properties:
        is_deployer: {type: boolean}
        scanned_nonces: {type: integer}
        truncated: {type: boolean, description: Nonce range exceeded `deployer_max_nonce`.}
        contracts:
          type: array
          items: {$ref: '#/components/schemas/WalletDeployedContract'}
        tokens_created: {type: integer}
        errors:
          type: object
          additionalProperties: {type: string}
          description: Networks where the scan failed (error class).

    WalletActivity:
      type: object
      required: [any_tx, chains_active, max_nonce]
      properties:
        any_tx: {type: boolean}
        chains_active:
          type: array
          items: {type: string}
        max_nonce: {type: integer}

    WalletUnresolved:
      type: object
      required: [chain, source, reason]
      properties:
        chain: {type: string}
        source: {type: string}
        reason:
          type: string
          enum: [offchain_lookup, upstream_error, not_mounted, timeout, budget, rpc_error, not_available, records_error]
          description: '`records_error` — the name was found but its text records could not be read (`fields=socials`); the name stays in `names`, without `records`.'

    WalletProfileMeta:
      type: object
      required: [cached, generated_at, pop, cost]
      properties:
        cached: {type: boolean}
        generated_at: {type: string, format: date-time}
        pop: {type: string, enum: [eu, us]}
        cost:
          type: object
          additionalProperties: {type: integer}
          description: 'Tokens charged per tier category for this request: `wallet_identity`, `<net>_read_rpc` / `bsc_read_rpc_heavy` for on-chain calls, `social_read` for external sources (Farcaster, Lens — one per source per address on a cache miss).'

    IntRange:
      type: object
      description: Integer comparison; at least one of the operators is required.
      properties:
        gte: {type: integer}
        gt: {type: integer}
        lte: {type: integer}
        lt: {type: integer}
        eq: {type: integer}

    DecimalRange:
      type: object
      description: Decimal comparison in coin units (string or number); at least one operator is required.
      properties:
        gte: {type: [string, number]}
        gt: {type: [string, number]}
        lte: {type: [string, number]}
        lt: {type: [string, number]}
        eq: {type: [string, number]}

    Filter:
      type: object
      description: |
        Predicate tree. Exactly one key per object (several keys = implicit
        `all`). Combinators: `all`, `any` (arrays of filters), `not` (filter).
        Leaf predicates read one profile group each; an unknown predicate is
        rejected with `400 invalid_filter`. Limits: 64 leaves, nesting 8.
      properties:
        all:
          type: array
          items: {$ref: '#/components/schemas/Filter'}
        any:
          type: array
          items: {$ref: '#/components/schemas/Filter'}
        not: {$ref: '#/components/schemas/Filter'}
        has_name:
          description: '`true`/`false`, or a list of sources (`ens`, `basenames`, `unstoppable`, `spaceid`) — verified names only.'
          oneOf:
            - {type: boolean}
            - {type: array, items: {type: string, enum: [ens, basenames, unstoppable, spaceid]}}
        name_matches:
          type: string
          description: Case-insensitive glob over verified names, e.g. `*.eth`.
        is_deployer: {type: boolean}
        tokens_created: {$ref: '#/components/schemas/IntRange'}
        deployed_symbol:
          type: string
          description: Case-insensitive glob over symbols of created ERC-20s, e.g. `PEPE*`.
        balance:
          allOf:
            - {$ref: '#/components/schemas/DecimalRange'}
            - type: object
              required: [chain]
              properties:
                chain: {type: string, description: Network id; a network absent from the profile counts as balance 0.}
        balance_any: {$ref: '#/components/schemas/DecimalRange'}
        nonce:
          allOf:
            - {$ref: '#/components/schemas/IntRange'}
            - type: object
              properties:
                chain: {type: string, description: Network id; omitted = max nonce across networks.}
        tx_count:
          allOf:
            - {$ref: '#/components/schemas/IntRange'}
            - type: object
              properties:
                chain: {type: string}
        active_on:
          type: array
          description: Every listed network must have nonce > 0.
          items: {type: string}
        is_contract:
          oneOf:
            - {type: boolean}
            - type: object
              required: [value]
              properties:
                chain: {type: string}
                value: {type: boolean}
        has_label:
          oneOf:
            - {type: boolean}
            - {type: array, items: {type: string, enum: [cex, bridge, solver, dex, mixer, fund, other]}}
        has_social:
          description: '`true`/`false`, or a list of platforms — at least one of them present in `socials`. Reads the `socials` group (and therefore `names`).'
          oneOf:
            - {type: boolean}
            - {type: array, items: {type: string, enum: [twitter, github, telegram, discord, reddit, youtube, website, avatar, header]}}
        fresh_wallet:
          type: boolean
          description: Monitoring subscriptions only; rejected in batch.

    WalletBatchRequest:
      type: object
      required: [addresses]
      properties:
        addresses:
          type: array
          minItems: 1
          maxItems: 500
          items: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
          description: Up to 100 (Business) / 500 (Enterprise). Duplicates are collapsed.
        chains:
          type: array
          items: {type: string, enum: [eth, base, polygon, bsc, robinhood, tron]}
          description: Networks to profile for `balances` / `activity` / `deployer`. When omitted — every network mounted on the PoP except Base (Base is opt-in for batches, name it explicitly); `meta.chains` echoes the effective list. Does not restrict `names` — use `name_sources`.
        name_sources:
          type: array
          items: {type: string, enum: [ens, basenames, spaceid, unstoppable, farcaster, lens]}
          description: Name services to query when `names` is requested (by the projection or the filter). Default — all, independent of `chains`; each on-chain service is charged to its own network's `<net>_read_rpc`, the external `farcaster` / `lens` to `social_read` (summed in `meta.cost`). An unknown id → `400 invalid_name_sources`.
        fields:
          type: array
          items: {type: string, enum: [names, socials, balances, activity, labels, deployer]}
          description: Projection of returned groups (default `names,balances,activity,labels`). `socials` is off by default and implies `names` (adds `socials[]` and `names[].records`). Groups the filter needs are computed regardless but returned only if listed here.
        filter: {$ref: '#/components/schemas/Filter'}
        return:
          type: string
          enum: [matched, all]
          default: matched
        deployer_max_nonce: {type: integer, minimum: 0, maximum: 5000, default: 2000}
        fresh: {type: boolean, default: false}

    WalletBatchResult:
      allOf:
        - {$ref: '#/components/schemas/WalletProfile'}
        - type: object
          required: [matched]
          properties:
            matched: {type: boolean}

    WalletBatchError:
      type: object
      required: [address, error]
      properties:
        address: {type: string}
        error: {type: string, description: '`invalid_address`, `all_chains_unavailable`, `timeout`, `internal`.'}
        message: {type: string}

    WalletBatchResponse:
      type: object
      required: [results, matched, total, errors, meta]
      properties:
        results:
          type: array
          items: {$ref: '#/components/schemas/WalletBatchResult'}
        matched: {type: integer, description: Addresses that passed the filter.}
        total: {type: integer, description: Distinct valid addresses processed.}
        errors:
          type: array
          items: {$ref: '#/components/schemas/WalletBatchError'}
        meta:
          type: object
          required: [generated_at, cost]
          properties:
            generated_at: {type: string, format: date-time}
            pop: {type: string}
            cost:
              type: object
              additionalProperties: {type: integer}
            chains: {type: array, items: {type: string}, description: Effective network list of this batch (after defaults — Base is excluded unless requested).}
            filter_fields: {type: string, description: Profile groups the filter read.}
            deduplicated: {type: integer}

    WalletWatchDelivery:
      type: object
      required: [type]
      properties:
        type: {type: string, enum: [webhook, ws, pull]}
        url: {type: string, format: uri, description: 'webhook only; must be https://'}
        secret: {type: string, description: webhook HMAC-SHA256 secret (masked as `***` in responses).}

    WalletWatchRequest:
      type: object
      properties:
        name: {type: string, maxLength: 128}
        chains:
          type: array
          items: {type: string, enum: [eth, base, polygon, bsc, robinhood]}
          description: Networks monitored on this PoP (Tron is not monitored — no receipts/archive).
        events:
          type: array
          items: {type: string, enum: [contract_deploy, smart_wallet_deploy, bridge_in, bridge_out, fresh_funding, delegation_set, wallet_activity]}
        filter: {$ref: '#/components/schemas/Filter'}
        enrich:
          type: array
          items: {type: string, enum: [names, socials, balances, activity, labels, deployer]}
          description: Profile groups attached to each participant (`participant.profile`). `socials` is off by default and implies `names`. Groups the filter needs are computed regardless.
        watchlist:
          type: array
          items: {type: string, pattern: '^0x[0-9a-fA-F]{40}$'}
          description: 'Only events where a participant is in this list (from / to / recipient / deployed contract). Required and non-empty for `wallet_activity`, whose events are the activity of exactly these addresses. Max 10 000 (Business) / 100 000 (Enterprise); change it with `POST /watch/{id}/watchlist`.'
        min_amount_usd: {type: number, minimum: 0, description: 'bridge_in/bridge_out only; needs a known USD price (stablecoins always).'}
        delivery: {$ref: '#/components/schemas/WalletWatchDelivery'}
        enabled: {type: boolean, default: true}
        min_confirmations:
          type: integer
          minimum: 0
          maximum: 64
          default: 0
          description: 'Hold each event until the network head is at least N blocks past the event''s block (0 = deliver at scanner depth). Upper bound is the smallest reorg window of the subscription''s networks (12 with `eth`, else 64) → `400 invalid_min_confirmations`. Applies to all non-system event types (`reorg_revert` / `gap` are never held). Held per (subscription, network): order is kept within a network, not across networks; a released event is stored again under a new `seq` with the same `event_id`. A value above 0 → `503 option_unavailable` until this deployment''s database supports the option. Omitted in responses when 0.'

    WalletWatchSubscription:
      allOf:
        - {$ref: '#/components/schemas/WalletWatchRequest'}
        - type: object
          required: [id, name, chains, events, delivery, enabled, created_at, updated_at]
          properties:
            id: {type: string, format: uuid}
            key_id: {type: string, format: uuid}
            created_at: {type: string, format: date-time}
            updated_at: {type: string, format: date-time}

    WalletWatchLimits:
      type: object
      description: 'Business: 5 / 5 / 10 000 / 10. Enterprise: 50 / 30 / 100 000; webhook throughput is currently 10 per subscription on every tier.'
      properties:
        MaxSubscriptions: {type: integer}
        MaxChains: {type: integer}
        MaxWatchlist: {type: integer}
        WebhookRPS: {type: integer}

    WalletWatchParticipant:
      type: object
      required: [address]
      properties:
        address: {type: string}
        profile: {$ref: '#/components/schemas/WalletProfile'}
        differs_from_sender: {type: boolean}

    WalletWatchEvent:
      type: object
      required: [seq, chain, block, type, ts]
      description: 'Same payload as the WebSocket and webhook frames (see `docs/api/streaming/wallet-watch.asyncapi.yaml`). `wallet_activity` has its own shape, `WalletActivityEvent`.'
      properties:
        seq: {type: integer, description: 'Position on the delivering PoP (PoP-local).'}
        event_id: {type: string, description: 'Stable id for deduplication (`ev_` + 32 hex), identical on every PoP and on redelivery; changes when a reorg moves the event to another block.'}
        cursor: {type: string, description: 'Opaque resume position (`c1.…`); pass as `cursor`.'}
        chain: {type: string}
        block: {type: integer}
        block_hash: {type: string}
        tx: {type: string}
        log_index: {type: integer}
        ts: {type: string, format: date-time}
        type: {type: string, enum: [contract_deploy, smart_wallet_deploy, bridge_in, bridge_out, fresh_funding, delegation_set, reorg_revert, gap]}
        bridge:
          type: object
          properties:
            protocol: {type: string}
            direction: {type: string, enum: [in, out]}
            src_chain: {type: string}
            dest_chain: {type: string}
            match_key: {type: string}
            contract: {type: string}
        amount:
          type: object
          properties:
            token: {type: string}
            symbol: {type: string}
            raw: {type: string}
            value: {type: string}
            usd: {type: number}
            decimals: {type: integer}
        contract: {type: string}
        factory: {type: string}
        kind: {type: string}
        delegate: {type: string}
        from: {$ref: '#/components/schemas/WalletWatchParticipant'}
        to: {$ref: '#/components/schemas/WalletWatchParticipant'}
        recipient: {$ref: '#/components/schemas/WalletWatchParticipant'}
        enrich: {type: string, enum: [done, skipped]}
        reverted:
          type: object
          properties:
            from_block: {type: integer}
            to_block: {type: integer}
        removed_event_ids:
          type: array
          items: {type: string}
          description: '`reorg_revert` only: ids of the events from the reverted blocks that this subscription received (possibly empty). Drop them.'
        removed_seqs:
          type: array
          items: {type: integer}
          description: '`reorg_revert` only: their seqs on the delivering PoP.'
        from_block: {type: integer, description: '`gap` only.'}
        to_block: {type: integer, description: '`gap` only.'}
        reason: {type: string, enum: [scanner_lag, rate_limited], description: '`gap` only.'}
        recoverable: {type: boolean, const: false, description: '`gap` only.'}

    WalletWatchGap:
      description: |
        System event `gap`: some events of `from_block`…`to_block` on `chain`
        will never be delivered. `scanner_lag` — the scanner skipped the range
        (sent to every subscription on the network); `rate_limited` — this
        subscription exceeded its `wallet_activity` events/s cap and part of
        its events in the range were dropped (sent to that subscription only,
        at most one per network per second).
      allOf:
        - {$ref: '#/components/schemas/WalletWatchEvent'}
        - type: object
          required: [type, from_block, to_block, reason, recoverable]
          properties:
            type: {type: string, const: gap}
            from_block: {type: integer}
            to_block: {type: integer}
            reason: {type: string, enum: [scanner_lag, rate_limited]}
            recoverable: {type: boolean, const: false}

    WalletWatchCursorExpired:
      type: object
      required: [error, recoverable, oldest_cursor]
      description: 'The cursor is older than this PoP retains (72 h). Events between it and `oldest_cursor` are lost. Same fields as the WebSocket `cursor_expired` frame.'
      properties:
        error: {type: string, const: cursor_expired}
        recoverable: {type: boolean, const: false}
        oldest_cursor: {type: string, description: 'Cursor of the oldest retained position; resume from here.'}
        code: {type: integer}
        message: {type: string}

    WalletActivityMovement:
      type: object
      required: [kind, direction, token, from, to, amount]
      properties:
        kind: {type: string, enum: [native, native_internal, erc20, erc721, erc1155], description: '`native` = outer tx value; `native_internal` = inside the call tree (trace); wrapped-native Deposit/Withdrawal are `erc20` from/to the zero address.'}
        direction: {type: string, enum: [in, out, self]}
        token: {type: string, description: '`native` or the token contract address.'}
        from: {type: string}
        to: {type: string}
        amount: {type: string, description: 'Exact amount in the smallest unit, decimal string (ERC-721 `"1"`). No decimals/symbol attached.'}
        token_id: {type: string, description: 'ERC-721 / ERC-1155 id, decimal string.'}
        log_index: {type: integer}
        trace_path: {type: string, description: '`native_internal` only: call-tree position, child indexes from the root joined with "." ("0" = first call of the root).'}

    WalletActivityEvent:
      type: object
      description: |
        `wallet_activity`: one event per (network, transaction, watched
        address). Same payload over WebSocket, webhook and the REST cursor.
      required: [type, event_id, seq, chain, chain_id, block, tx, status, error, confirmation, address, matched_by, movements, coverage]
      properties:
        type: {type: string, const: wallet_activity}
        event_id: {type: string, description: '`wa_` + first 32 hex of sha256("<chain_id>|<block_hash>|<tx_hash>|<address>"). Stable across PoPs and redeliveries; changes only when a reorg moves the tx to another block.'}
        seq: {type: integer, description: 'PoP-local position.'}
        cursor: {type: string, description: 'Opaque resume position (`c1.…`).'}
        chain: {type: string, enum: [eth, base, bsc, robinhood, polygon]}
        chain_id: {type: integer, enum: [1, 8453, 56, 4663, 137]}
        block:
          type: object
          required: [number, hash, timestamp]
          properties:
            number: {type: integer}
            hash: {type: string}
            timestamp: {type: string, format: date-time}
        tx:
          type: object
          required: [hash, index, from, to, value, nonce, type, method_id]
          properties:
            hash: {type: string}
            index: {type: integer}
            from: {type: string}
            to: {type: [string, 'null'], description: '`null` for a contract creation.'}
            created_contract: {type: string, description: 'Created contract (creation txs only).'}
            value: {type: string, description: 'Wei, decimal string.'}
            nonce: {type: integer}
            type: {type: integer}
            method_id: {type: [string, 'null'], description: 'First 4 bytes of calldata; `null` without calldata.'}
            gas_used: {type: string}
            effective_gas_price: {type: string}
            fee: {type: string, description: 'gas_used × effective_gas_price (+ l1_fee), wei. Reported for reverted txs too.'}
            l1_fee: {type: string, description: 'L1 data fee (Base).'}
        status: {type: string, enum: [success, reverted, unknown], description: 'From the receipt; `unknown` = receipt not read (`coverage.logs: unavailable`). Independent of `confirmation`.'}
        error:
          type: [object, 'null']
          description: '`null` unless reverted.'
          required: [message, source]
          properties:
            message: {type: [string, 'null'], description: 'Revert reason / Error(string) / Panic(uint256) / frame error ("out of gas"); `null` when not traced or when the revert data is a custom error.'}
            source: {type: string, enum: [trace, unavailable]}
            data: {type: string, description: 'Raw revert data (hex) when not decoded; decode with the contract ABI.'}
        confirmation:
          type: object
          required: [depth, reorg_window, state]
          properties:
            depth: {type: integer, description: 'Block depth when sent, counting the block (ETH 1, Base/BSC/Polygon 2, Robinhood 3 at scanner depth; more with `min_confirmations`).'}
            reorg_window: {type: integer, description: 'Blocks watched for reorgs (ETH 12, others 64).'}
            state: {type: string, enum: [unsafe, safe, finalized], description: '`unsafe` today for every event; `safe` / `finalized` are reserved.'}
        address: {type: string, description: 'The watched address (lowercase).'}
        matched_by:
          type: array
          description: 'Why the address matched, in this fixed order, without duplicates. `authorization_7702` is reserved (not emitted yet).'
          items:
            type: string
            enum: [tx_from, tx_to, contract_created, native_in, native_out, native_internal_in, native_internal_out, erc20_in, erc20_out, erc721_in, erc721_out, erc1155_in, erc1155_out, approval, weth_deposit, weth_withdrawal, log_topic, log_data, bridge_out, bridge_in_recipient, authorization_7702]
        movements:
          type: array
          items: {$ref: '#/components/schemas/WalletActivityMovement'}
        logs: {type: array, items: {type: object}, description: 'Reserved for an opt-in `include=logs`; not emitted yet.'}
        bridge:
          type: object
          required: [protocol, direction, link]
          description: 'Present when the tx is a deposit/fill of a bridge in Triport''s ledger. Links are never inferred.'
          properties:
            protocol: {type: string, enum: [across, stargate, debridge, relay, base_native, polygon_pos, robinhood_native]}
            direction: {type: string, enum: [in, out]}
            src_chain: {type: string}
            dest_chain: {type: string}
            recipient: {type: string}
            recipient_differs: {type: boolean, description: 'Recipient ≠ depositor.'}
            match_key: {type: string, description: 'Protocol-emitted id (deposit id / order id / GUID); the same value on the source and destination events. Absent without a protocol id.'}
            link: {type: string, enum: [protocol_id, none]}
        coverage:
          type: object
          required: [logs, internal_native]
          properties:
            logs: {type: string, enum: [full, unavailable]}
            internal_native: {type: string, enum: [traced, matched_tx_only, matched_tx_skipped, unavailable], description: '`traced` = every block traced; `matched_tx_only` = only txs already matched are traced; `matched_tx_skipped` = tracing was skipped (budget/timeout/upstream); `unavailable` = no tracing on this network.'}
        enrich: {type: string, description: 'Not used by `wallet_activity` in v1.'}

    WalletWatchCoverage:
      type: object
      required: [generated_at, chains, retention, limits]
      properties:
        generated_at: {type: string, format: date-time}
        chains:
          type: array
          items:
            type: object
            required: [chain, chain_id, levels, confirmation, scanner]
            properties:
              chain: {type: string}
              chain_id: {type: integer}
              levels:
                type: object
                properties:
                  logs: {type: string, enum: [full]}
                  internal_native: {type: string, enum: [traced, matched_tx_only, unavailable]}
              confirmation:
                type: object
                properties:
                  confirmations: {type: integer}
                  depth_at_emit: {type: integer}
                  reorg_window: {type: integer}
                  state_at_emit: {type: string, enum: [unsafe, safe, finalized]}
              scanner:
                type: object
                properties:
                  state: {type: string, enum: [running, starting, not_started]}
                  poll_interval_ms: {type: integer}
                  max_lag: {type: integer, description: 'Blocks behind head after which the scanner skips ahead and emits `gap`.'}
                  head: {type: integer}
                  processed_block: {type: integer}
                  lag: {type: integer}
        retention:
          type: object
          properties:
            max_age_seconds: {type: integer}
            max_bytes: {type: integer}
            oldest_seq: {type: integer}
            latest_seq: {type: integer}
            oldest_event_at: {type: [string, 'null'], format: date-time}
            oldest_event_age_seconds: {type: [integer, 'null']}
            seq_scope: {type: string, const: pop}
        limits:
          type: array
          items:
            type: object
            required: [id, detail]
            properties:
              id: {type: string}
              detail: {type: string}

    WalletResearchTx:
      type: object
      required: [hash, block, time]
      properties:
        hash: {type: string}
        block: {type: integer}
        time: {type: string, format: date-time}
    WalletResearchEvent:
      allOf:
        - {$ref: '#/components/schemas/WalletResearchTx'}
        - type: object
          properties:
            kind: {type: string}
            direction: {type: string, enum: [in, out, self]}
            counterparty: {type: string}
            asset: {type: string, description: native or ERC20 contract address.}
            amount_raw: {type: string, pattern: '^[0-9]+$'}
            success: {type: [boolean, 'null']}
            protocol: {type: string}
            labels: {type: array, items: {$ref: '#/components/schemas/WalletLabel'}}
    WalletResearchChain:
      type: object
      properties:
        status: {type: string, enum: [ok, partial, complete, error]}
        error: {type: string}
        approval_block: {type: integer, description: Canonical anchor height used to verify approvals discovered in this window.}
        coverage:
          type: [object, 'null']
          properties:
            from_block: {type: integer}
            to_block: {type: integer}
            to_hash: {type: string}
            from: {type: string, format: date-time}
            to: {type: string, format: date-time}
            period_complete: {type: boolean}
            history_from_genesis: {type: boolean}
        first_observed_tx:
          anyOf: [{$ref: '#/components/schemas/WalletResearchTx'}, {type: 'null'}]
        last_observed_tx:
          anyOf: [{$ref: '#/components/schemas/WalletResearchTx'}, {type: 'null'}]
        observed_age_days: {type: [integer, 'null']}
        activity_status: {type: string, enum: [fresh, active, dormant, unknown]}
        transactions: {type: integer}
        failed_transactions: {type: integer}
        unknown_status_transactions: {type: integer}
        volumes:
          type: array
          items:
            type: object
            properties:
              asset: {type: string}
              in_raw: {type: string}
              out_raw: {type: string}
        activity: {type: array, items: {$ref: '#/components/schemas/WalletResearchEvent'}}
        flows: {type: array, items: {$ref: '#/components/schemas/WalletResearchEvent'}}
        counterparties:
          type: array
          maxItems: 20
          items:
            type: object
            properties:
              address: {type: string}
              transactions: {type: integer}
              labels: {type: array, items: {$ref: '#/components/schemas/WalletLabel'}}
        approvals:
          type: array
          items:
            allOf:
              - {$ref: '#/components/schemas/WalletResearchTx'}
              - type: object
                properties:
                  token: {type: string}
                  spender: {type: string}
                  kind: {type: string, enum: [erc20, erc721, operator]}
                  token_id: {type: string}
                  observed_amount_raw: {type: string}
                  current_amount_raw: {type: [string, 'null']}
                  status: {type: string, enum: [active, revoked, unverified]}
                  labels: {type: array, items: {$ref: '#/components/schemas/WalletLabel'}}
        cluster:
          type: object
          properties:
            method: {type: string}
            status: {type: string, enum: [not_observed, heuristic, service_source_excluded, system_source_excluded]}
            source: {type: string}
            funding: {$ref: '#/components/schemas/WalletResearchTx'}
            members: {type: array, maxItems: 100, items: {type: string}}
            has_more: {type: boolean}
            labels: {type: array, items: {$ref: '#/components/schemas/WalletLabel'}}
        has_more: {type: boolean}
        volumes_has_more: {type: boolean}
        flows_has_more: {type: boolean}
        approvals_has_more: {type: boolean}
        counterparties_has_more: {type: boolean}
    WalletResearch:
      type: object
      required: [address, from, to, generated_at, chains, limit, offset, sort, limitations]
      properties:
        source: {type: string, description: Result source identifier.}
        scan_cursor: {type: string, description: Replays this block window for limit/offset pagination.}
        next_cursor: {type: string, description: Reads older windows; absent when traversal is complete.}
        scan_blocks: {type: integer}
        address: {type: string}
        from: {type: string, format: date-time}
        to: {type: string, format: date-time}
        generated_at: {type: string, format: date-time}
        chains: {type: object, additionalProperties: {$ref: '#/components/schemas/WalletResearchChain'}}
        limit: {type: integer}
        offset: {type: integer}
        sort: {type: string, enum: [time, amount]}
        limitations: {type: array, items: {type: string}}
    WalletProfile:
      type: object
      required: [address, unresolved, meta]
      properties:
        address: {type: string, description: Lower-case EVM address.}
        address_tron: {type: string, description: Tron base58 form of the same key.}
        names:
          type: array
          items: {$ref: '#/components/schemas/WalletName'}
          description: Present (possibly `[]`) when `names` or `socials` was requested; absent otherwise.
        socials:
          type: array
          items: {$ref: '#/components/schemas/WalletSocial'}
          description: Present (possibly `[]`) when `socials` was requested; absent otherwise.
        labels:
          type: array
          items: {$ref: '#/components/schemas/WalletLabel'}
        chains:
          type: object
          additionalProperties: {$ref: '#/components/schemas/WalletChainState'}
        deployer: {$ref: '#/components/schemas/WalletDeployer'}
        activity: {$ref: '#/components/schemas/WalletActivity'}
        unresolved:
          type: array
          items: {$ref: '#/components/schemas/WalletUnresolved'}
        meta: {$ref: '#/components/schemas/WalletProfileMeta'}
