openapi: 3.1.0
info:
  title: DolaRamp Wallet API
  version: "1.1"
  description: |
    Non-custodial stablecoin wallet infrastructure. Receive USDT/USDC/PYUSD across
    Solana, TRON, gram(TON), Base and Arbitrum, consolidate with gasless sweeps, and
    pay out — all under one API key. On Arbitrum, USDT is the USDT0 deployment
    (Tether's official omnichain USDT — the converted legacy contract), reported
    under the `USDT` token symbol.

    **Custody model (envelope).** When you create a hotwallet we return a
    `wallet_secret` (`ws_...`) **once**. It encrypts your hotwallet key and every
    child key; we store only the ciphertext. Sweep, withdraw, export and child
    generation require the secret in the request body — without it, not even
    DolaRamp can move your funds. Store it safely.

    **The flow.** create a hotwallet → generate child wallets (deposit addresses,
    one per user) → receive deposits (webhook `deposit.confirmed` or `GET /deposits`)
    → sweep children into the hotwallet → withdraw to anywhere. We sponsor the gas;
    our fee is taken inside the transaction in stablecoin.

    **API keys.** Each key has a scope: `read` (list, quote, poll) or `operate`
    (everything, including sweep, withdraw, bridge and key management). Writes
    with a `read` key answer `403 insufficient_scope`. Existing keys are `operate`.

    **Integration flows.** Seven node maps — store, gateway, exchange, on-ramp only,
    off-ramp only, mass payouts, crypto card — with the exact calls and webhooks each
    one uses: https://dolaramp.com/docs/fluxos/

    **Arriving vs confirmed.** Deposits are reported the moment they are seen and
    again when the network's own requirement is met. Release goods on
    `deposit.confirmed` / `state: "confirmed"` — never on the first signal. See
    `GET /deposits`.

    **The bridge is the issuer's, not ours.** Moving USDC between networks runs on
    **Circle's CCTP**: burned on the origin chain, minted by Circle on the destination
    chain, one to one. We hold **no liquidity pool, reserve or float** — we never take
    the other side of your transfer, so there is no pool of ours to run dry or be
    drained. We drive the protocol, sponsor the gas and report both hashes. Ceiling:
    **$10,000,000 per operation** (ours, and lowerable at any time); Circle's
    fast-transfer capacity is a shared allowance that moves with global demand, and if
    it is short at that moment Circle completes on its slower path rather than failing.
    A burn always ends in a mint — attestations do not expire, so a delayed transfer is
    delayed, never lost. On EVM origins the funds pass through our relayer address for
    seconds between the pull and the burn; that is the only point where we touch them.

    **Rates are mid-market.** `GET /rates` is a reference quote (USDT/fiat on real
    exchanges). We do not swap or trade — apply your own spread to lock your margin.

    **Getting a key.** Sign up in the panel (dolaramp.com/painel) with Google, GitHub
    or an email code — your first `dr_live_...` key is issued there. Manage keys with
    `GET/POST /keys`. Send it as `X-Api-Key` on every call.

    **Errors.** Every error is `{ "error": "<code>", "reason"?: "<detail>" }` with a
    matching HTTP status. Common: `400` (`invalid_request` with `reason: malformed_json`
    when the body is not valid JSON; field errors list the failing fields),
    `401` (bad/missing key or wrong wallet_secret),
    `402` (address limit reached, or balance reserved for accrued fees), `403`
    (`ip_not_allowlisted`, `client_suspended`), `404`, `409` (`op_in_progress`, `would_lock_out`),
    `422` (`sponsor_refused`), `429` (rate limit — 120 req/min per key), `503`
    (gas sponsor out of funds / not configured, or `gasfree_unavailable` with
    `retry: true` when a TRON address cannot be created right now).

    **Deferred fees (TRON).** On the GasFree rail the network charges the client
    1.5 USDT per transfer (+1.5 once to activate a new account) inside the tx; OUR
    fee ($0.10 per operation) accrues instead of being charged each time: it stays
    reserved in your hotwallet and is collected in ONE batch once it reaches $50.
    The network fee of that collecting transfer is on us — you pay exactly what
    accrued, nothing more. `GET /me` exposes `accrued_fees_usd`.
    What a TRON withdraw can send is therefore `balance − accrued fees − 1.5`
    (network fee of that transfer). A payout above it is refused with
    `400 insufficient_balance` carrying `balance`, `available` and
    `reserved_for_fees` (the `withdraw.failed` webhook carries the same as
    `balance_usd`, `available_usd`, `reserved_usd`), unless `allow_partial: true`;
    `402 reserved_for_fees` when the balance covers only the reserve. Size payouts
    from `available`, or top up by the reserve.
servers:
  - url: https://api.dolaramp.com/v1
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Account
    description: Who you are, plans, API keys.
  - name: Setup
    description: Create a hotwallet and generate deposit addresses.
  - name: Receive
    description: Deposits and webhooks.
  - name: Move funds
    description: Quote, sweep and withdraw.
  - name: Check
    description: Balances, address validation, market rates.
  - name: Advanced
    description: Export keys, reset a network.

paths:
  /me:
    get:
      tags: [Account]
      summary: Client + plan + usage
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tier: { type: string, enum: [free, pro, enterprise] }
                  subscription_status: { type: string }
                  fee_floor_usd: { type: string, example: "0.05" }
                  fee_multiplier: { type: number, example: 1.5 }
                  addresses_used: { type: integer, example: 1240 }
                  addresses_watched: { type: integer, example: 1240 }
                  addresses_limit: { type: integer, example: 50000 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /keys:
    get:
      tags: [Account]
      summary: List API keys
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  caller_ip:
                    type: string
                    nullable: true
                    description: The IP our edge attributes to this request — the one an allowlist would judge you by (IPv4 or IPv6).
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        prefix: { type: string, example: "dr_live_ab" }
                        scope:
                          type: string
                          enum: [read, operate]
                          description: "`read` can list, quote and poll; `operate` can also mint keys, create/export/delete hotwallets, sweep, withdraw, bridge and manage webhooks/alerts."
                        active: { type: boolean }
                        created_at: { type: string, format: date-time }
    post:
      tags: [Account]
      summary: Generate a new API key
      description: |
        The plaintext key is returned **once** — store it now.

        Pick the smallest scope that does the job: a `read` key is enough for
        dashboards, reconciliation and deposit polling, and cannot move funds
        even with the `wallet_secret`. Any write with a `read` key answers
        `403 insufficient_scope`. Default is `operate` for compatibility.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 64, example: "backend-1" }
                scope: { type: string, enum: [read, operate], default: operate }
      responses:
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key: { type: string, example: "dr_live_..." }
                  scope: { type: string, enum: [read, operate] }
                  warning: { type: string }

  /keys/{id}/revoke:
    post:
      tags: [Account]
      summary: Revoke a key
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Revoked }
        "400": { description: "cannot_revoke_current_key — you can't revoke the key you're authenticating with" }

  /keys/{id}/ip-allowlist:
    post:
      tags: [Account]
      summary: Lock a key to specific IPs
      description: |
        When set, the key only works from these exact IPs (a request from any other
        IP gets `403 ip_not_allowlisted`). Send an empty array to clear the lock.

        **The lock also applies to your panel session** (the union of every active
        key's list). A list that leaves out the IP you are calling from would lock
        you out on the very next request, so the API refuses it with
        `409 would_lock_out` and tells you the IP it sees (`caller_ip`). Send
        `confirm: true` to save it anyway — e.g. when locking a key to a server
        that is not the machine you are calling from. Mind IPv4 vs IPv6: the IP
        that counts is the one our edge sees, which `GET /keys` returns as
        `caller_ip`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ips]
              properties:
                ips:
                  type: array
                  maxItems: 20
                  description: IPv4 or IPv6, exact match.
                  items: { type: string }
                confirm:
                  type: boolean
                  description: Save even if the list leaves out the IP you are calling from.
            example: { ips: ["203.0.113.10", "203.0.113.11"] }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  ip_allowlist: { type: array, items: { type: string } }
                  restricted: { type: boolean }
        "409":
          description: "would_lock_out — the list does not contain the IP you are calling from. Add it, or resend with `confirm: true`."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: conflict }
                  reason: { type: string, example: would_lock_out }
                  caller_ip: { type: string, nullable: true, example: "2001:db8::1" }
                  ips: { type: array, items: { type: string } }

  /hotwallets:
    post:
      tags: [Setup]
      summary: Create a hotwallet (one per network)
      description: |
        Returns the `wallet_secret` **once**. It unlocks children, sweep, withdraw
        and export for this hotwallet. We never keep a copy.

        **Unified EVM address:** creating a hotwallet on `base` or `arbitrum`
        creates it on BOTH — one address, one `wallet_secret`, valid on every
        EVM network (`evm_unified: true`, `networks` in the response). If you
        already had one EVM network from before, creating the other mirrors it
        (same address; your existing secret applies — `mirrored_from` says so
        and no new secret is returned). Children inherit this: one deposit
        address per child, observed on both chains, counted once against your
        address cap. Funds still move per network (sweep/withdraw take
        `network`), exactly like an exchange deposit address.

        **TRON is always gasless:** a `tron` hotwallet is created together with
        its gasless account, and `deposit_address` is that account. If the
        gasless address cannot be resolved at that moment the call answers
        `503` with `reason: gasfree_unavailable` and `retry: true`, and nothing
        is created. Repeat the same call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [network]
              properties:
                network: { $ref: "#/components/schemas/Network" }
                label: { type: string, maxLength: 64 }
            example: { network: solana, label: "acme main" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  address: { type: string }
                  deposit_address: { type: string, description: "TRON: the GasFree account to fund/receive on." }
                  wallet_secret: { type: string, example: "ws_...", description: "Shown once. Store it. Absent when an EVM hotwallet is mirrored from its sibling network (the existing secret applies)." }
                  evm_unified: { type: boolean, description: "EVM only: this address and secret cover base AND arbitrum." }
                  networks: { type: array, items: { $ref: "#/components/schemas/Network" }, description: "EVM only: every network this hotwallet exists on." }
                  mirrored_from: { $ref: "#/components/schemas/Network" }
                  warning: { type: string }
              example:
                network: solana
                address: "yM8mdmDynPkdve8XTsiZL6fbK5rYfrCLR1sGkvNLE2q"
                wallet_secret: "ws_9f3c1e8a...store-this-once"
                warning: "STORE wallet_secret NOW — shown once, never persisted by us."
        "409": { description: One hotwallet per network — already exists }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/GasFreeUnavailable" }
    get:
      tags: [Setup]
      summary: List hotwallets
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  hotwallets:
                    type: array
                    items:
                      type: object
                      properties:
                        network: { $ref: "#/components/schemas/Network" }
                        address: { type: string }
                        children_count: { type: integer }
                        created_at: { type: string, format: date-time }

  /hotwallets/{network}/reset-code:
    post:
      tags: [Advanced]
      summary: Email a code to delete this network (lost wallet_secret)
      description: |
        Step 1 of the recovery path of `DELETE /hotwallets/{network}`. Mints a
        6-digit code and emails it to **your account's own address** — the one you
        sign in to the panel with. Nothing is deleted here.

        The code is what authorises a delete when you no longer have the
        `wallet_secret`: an API key alone must not be able to destroy your key
        blobs, and your inbox is the one factor a stolen key does not confer.

        Bounds: the code is valid for **10 minutes**, one send every **120s**, and
        **5 sends per hour** per account — shared across networks, so cycling
        `{network}` does not buy you more. Minting a new code invalidates the
        previous one for the same network. The code is bound to the network it was
        minted for: a code for `ton` will not delete `solana`, and a panel login
        code will not delete anything. The one exception is the **unified EVM
        pair**: when base and arbitrum share one address (one envelope), the
        code covers **both** networks — the delete removes the pair, and
        `covers_networks` in the response says so.

        No body. Requires an existing hotwallet on the network.
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
      responses:
        "200":
          description: Code sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean }
                  network: { $ref: "#/components/schemas/Network" }
                  covers_networks:
                    type: array
                    items: { $ref: "#/components/schemas/Network" }
                    description: |
                      Networks this code authorises deleting. `[network]` normally;
                      `[base, arbitrum]` when they are a mirrored (unified) pair.
                  email_hint:
                    type: string
                    description: Masked account address, e.g. `o***r@example.com`.
                  expires_in_s: { type: integer, example: 600 }
                  retry_in_s: { type: integer, example: 120 }
              example:
                sent: true
                network: solana
                email_hint: "o***r@example.com"
                expires_in_s: 600
                retry_in_s: 120
        "404": { description: No hotwallet on this network }
        "429":
          description: |
            `cooldown` (a code was just sent) or `rate_limited` (hourly cap).
            Both carry `retry_in_s`.
        "502": { description: "`mail_send_failed` — the email could not be delivered." }
        "503":
          description: |
            `email_not_configured` — this deployment cannot send email, so the
            recovery path is unavailable. Write to hello@dolaramp.com.

  /hotwallets/{network}:
    delete:
      tags: [Advanced]
      summary: Reset a network (delete hotwallet + children)
      description: |
        Deletes the hotwallet and all its children so you can create a fresh one.

        **This destroys the only copy of the encrypted keys.** It cascades to every
        child wallet, and the ciphertext is not recoverable afterwards — any balance
        still sitting on those addresses becomes permanently unreachable, including
        by you.

        `acknowledge: true` is always required. Beyond it, there are **two ways to
        authorise the delete, and you must send exactly one of them** — a request
        carrying both is rejected:

        1. **`wallet_secret`** — the safe route. Validated by decrypting your
           hotwallet blob. Because a caller who holds the secret can still sweep,
           this path answers `409 balance_present` while any address holds funds;
           pass `force: true` only if you accept losing them.
        2. **`reset_code`** — for a **lost `wallet_secret`**, which is why this
           endpoint exists at all: without it you would be stuck forever (no
           children, no sweep, no withdraw, no export, and `409` on a second
           hotwallet for the network). Get the code from
           `POST /hotwallets/{network}/reset-code`; it is emailed to your account
           address. This path does **not** refuse on balance — those funds are
           already unreachable without the secret — and `force` is rejected here
           because there is nothing to force past. The `200` reports what was
           stranded in `balance_stranded`.

        An API key alone is deliberately **not** enough: a stolen `dr_live_*` could
        otherwise destroy every deposit address under a network — funds the thief
        could never spend but could permanently deny. Note the earlier 2026-08-01
        wording that required the `wallet_secret` unconditionally, and pointed the
        lost-secret case at support, is obsolete: it was circular (it asked for the
        password to shred the envelope whose password was lost).

        **Unified EVM pair:** when base and arbitrum share one address (one
        envelope, one `wallet_secret`), deleting **either** network deletes
        **both**, in one transaction, under one authorisation — the pair is one
        infra and a one-sided delete would leave a half-alive twin.
        `networks_removed` reports what actually went, the balance refusal scans
        both chains, and `children_removed` counts **distinct addresses** (a
        mirrored child is one address). Legacy pairs with distinct addresses keep
        the per-network behaviour.
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - title: With the wallet_secret
                  type: object
                  required: [acknowledge, wallet_secret]
                  properties:
                    acknowledge: { type: boolean, enum: [true] }
                    wallet_secret: { type: string, example: "ws_..." }
                    force:
                      type: boolean
                      default: false
                      description: |
                        Delete even when a balance is present. Those funds become
                        unreachable. Sweep or withdraw first unless you mean it.
                - title: With an emailed code (lost wallet_secret)
                  type: object
                  required: [acknowledge, reset_code]
                  properties:
                    acknowledge: { type: boolean, enum: [true] }
                    reset_code:
                      type: string
                      pattern: "^[0-9]{6}$"
                      example: "418302"
                      description: |
                        From `POST /hotwallets/{network}/reset-code`. Single use,
                        10-minute life, 5 wrong attempts burn it, bound to this
                        network.
            examples:
              with_secret:
                summary: I have my wallet_secret
                value: { acknowledge: true, wallet_secret: "ws_9f3c..." }
              lost_secret:
                summary: I lost my wallet_secret
                value: { acknowledge: true, reset_code: "418302" }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  network: { $ref: "#/components/schemas/Network" }
                  networks_removed:
                    type: array
                    items: { $ref: "#/components/schemas/Network" }
                    description: |
                      `[network]` normally; `[base, arbitrum]` when the unified EVM
                      pair was deleted together.
                  evm_unified:
                    type: boolean
                    description: Present (true) when the mirrored EVM pair was deleted as one.
                  children_removed:
                    type: integer
                    description: Distinct child addresses removed (a mirrored EVM child counts once).
                  authorized_by:
                    type: string
                    enum: [wallet_secret, email_code]
                  forced: { type: boolean, description: "Present only when `force: true` was used." }
                  balance_stranded:
                    type: array
                    description: |
                      `reset_code` path only — what was still on the addresses when
                      they were destroyed. Now permanently unreachable.
                    items:
                      type: object
                      properties:
                        address: { type: string }
                        kind: { type: string, enum: [hotwallet, child] }
                        token: { type: string }
                        balance_usd: { type: string }
                        network:
                          $ref: "#/components/schemas/Network"
                          description: Present only when the delete spanned the mirrored EVM pair.
                  balance_unverified:
                    type: boolean
                    description: |
                      `reset_code` path only — true when at least one balance could
                      not be read (or was above the 100-child check cap), so
                      `balance_stranded` may be incomplete.
                  stranded_note: { type: string }
        "400":
          description: |
            Neither factor sent, both sent at once, `acknowledge` missing, or
            `force` combined with `reset_code`.
        "401":
          description: |
            `invalid_wallet_secret`, or `invalid_code` / `expired` on the
            `reset_code` path (`reason: reset_code_rejected`).
        "404": { description: No hotwallet on this network }
        "409":
          description: |
            `balance_present` — `wallet_secret` path only: the hotwallet or one of
            its children still holds funds. Sweep/withdraw first, or pass
            `force: true`.
        "429": { description: "`too_many_attempts` — the code was burned by wrong tries. Request a new one." }
        "503":
          description: |
            `email_not_configured` — a `reset_code` was sent but this deployment
            cannot send email, so no code could exist. Write to hello@dolaramp.com.

  /hotwallets/{network}/export:
    post:
      tags: [Advanced]
      summary: Export the hotwallet private key
      description: |
        The non-custodial escape hatch — import it into any wallet. Requires the
        wallet_secret. On TRON, any accrued DolaRamp fees are settled first (no $50
        floor here, since the reserve stops being reachable through us once you hold
        the key); the export never waits on that — whatever could not be settled comes
        back as `accrued_fees_usdt` with `fee_note: could_not_settle_on_export`.
        After an export the hotwallet keeps working exactly as before on TRON
        (fees still accrue and are collected in the $50 batch); on TON a withdraw
        needs the hotwallet to hold its own TON for gas (`gas_required_key_exported`
        otherwise), since the gas we front cannot be reclaimed from a wallet whose
        key we no longer hold alone.
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SecretBody" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  address: { type: string }
                  private_key: { type: string }
                  fee_collection: { type: object, description: "TRON only — present when accrued fees were settled on export." }
                  accrued_fees_usdt: { type: string, description: "TRON only — what could not be settled." }
                  fee_note: { type: string, example: could_not_settle_on_export }
        "401": { description: "invalid_wallet_secret" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/WalletSecretLocked" }

  /hotwallets/{network}/children:
    post:
      tags: [Setup]
      summary: Generate child wallets (deposit addresses)
      description: |
        Each child is a deposit address — hand one to each of your users. Up to
        **100 per request** (each child mints an encrypted envelope server-side);
        loop for more. Optionally attach your own `external_ref` per child.

        **Unified EVM:** a child created on `base` or `arbitrum` is ONE address
        valid and observed on both networks (`evm_unified: true` in the
        response) and counts once against your address cap.

        **TRON is always gasless:** every `tron` child is created with its
        gasless account, and `deposit_address` is that account. The request is
        all or nothing: if any gasless address in the batch cannot be resolved
        the call answers `503` with `reason: gasfree_unavailable` and
        `retry: true`, and no child is created. Repeat the same call.
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [wallet_secret]
              properties:
                wallet_secret: { type: string, example: "ws_..." }
                count: { type: integer, minimum: 1, maximum: 100, default: 1 }
                external_refs:
                  type: array
                  items: { type: string, maxLength: 128 }
                  description: Your own user/invoice ids, one per child (length must equal count).
            example:
              wallet_secret: "ws_9f3c..."
              count: 3
              external_refs: ["user_100", "user_101", "user_102"]
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  evm_unified: { type: boolean, description: "EVM only: each address below is valid and observed on base AND arbitrum." }
                  networks: { type: array, items: { $ref: "#/components/schemas/Network" } }
                  children:
                    type: array
                    items: { $ref: "#/components/schemas/Child" }
              example:
                children:
                  - { address: "ANjK0x...ktZ", deposit_address: "ANjK0x...ktZ", external_ref: "user_100", created_at: "2026-07-17T14:00:00Z" }
                  - { address: "ANjK1x...ktZ", deposit_address: "ANjK1x...ktZ", external_ref: "user_101", created_at: "2026-07-17T14:00:00Z" }
        "402": { $ref: "#/components/responses/AddressLimit" }
        "401": { description: "invalid_wallet_secret" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/WalletSecretLocked" }
        "503": { $ref: "#/components/responses/GasFreeUnavailable" }
    get:
      tags: [Setup]
      summary: List child wallets (searchable, paginated)
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
        - { name: limit, in: query, schema: { type: integer, default: 100, maximum: 500 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: ref, in: query, schema: { type: string }, description: Filter by your external_ref (substring). }
        - { name: with_balance, in: query, schema: { type: string, enum: ["1"] }, description: Include per-child balance (page ≤ 50). }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
                  children:
                    type: array
                    items: { $ref: "#/components/schemas/Child" }

  /quote:
    get:
      tags: [Move funds]
      summary: Live gas-abstraction fee quote
      description: |
        What a sweep/withdraw will cost before you run it: `gas_real × 1.1 + tier margin`
        ($0.10 free, $0.05 pro) in stablecoin, taken inside the transaction. On Base and
        Arbitrum the gas is ours, so the fee is the margin alone. On TRON the network fee
        is charged by the gasless rail in USDT and is returned separately as
        `network_fee_usdt`; our fee is the margin alone. Typical vs worst-case.
        On Solana the worst case is a destination that has never held that token:
        the transfer also pays once to create its token account (network rent,
        ~$0.24 at today's SOL price), so ~$0.34 free / ~$0.29 pro that first time —
        your hotwallet on its first sweep, or a new withdrawal address. Creating
        children and receiving deposits cost nothing: the sender pays for the
        child's token account. On TRON the worst case is an inactive account.
      parameters:
        - { name: network, in: query, schema: { $ref: "#/components/schemas/Network" }, description: "Defaults to solana." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  typical: { type: object, description: "{ fee_usdt, floored_at_minimum, network_fee_usdt (tron only) }" }
                  worst_case_new_accounts: { type: object, description: "ceiling if the transfer must create token accounts (destination and fee address both new to the token); the usual first-time case is one account" }
                  tier: { type: string, enum: [free, pro, enterprise] }
                  formula: { type: string, example: "gas_usd x 1.1 + $0.10" }
                  min_sweep_usdt: { type: string }
              example:
                network: solana
                typical: { fee_usdt: "0.10", floored_at_minimum: false }
                worst_case_new_accounts: { fee_usdt: "0.65", floored_at_minimum: false }
                tier: free
                formula: "gas_usd x 1.1 + $0.10"

  /bridge/quote:
    get:
      tags: [Move funds]
      summary: Bridge quote — move USDC between networks (CCTP v2)
      description: |
        Prices moving USDC between **solana, base and arbitrum** — the same
        asset on both sides, burned and minted 1:1 by Circle (CCTP v2). This
        is a **network move, not a swap**: no pool, no slippage.

        **Only `fast` executes today** (8-20s; Circle charges 1-1.4 bps,
        shown as `network_fee_usd`). The quote also prices `standard`
        (free, waits full finality — ~25s from Solana, ~13-19 min from an
        EVM chain) so you can see the trade, but `POST /v1/bridge` accepts
        `mode: fast` only and rejects anything else with a 400.

        Our fee: **flat + percent of the amount** (percent varies by tier),
        **minimum $10 per operation**. Pass `amount` (micro-units) to get the
        operation itemized; omit it for the fee schedule only.

        `POST /v1/bridge` (execution) is live for every pair of
        solana / base / arbitrum, in both directions.
      parameters:
        - { name: from, in: query, required: true, schema: { type: string, enum: [solana, base, arbitrum] } }
        - { name: to, in: query, required: true, schema: { type: string, enum: [solana, base, arbitrum] } }
        - { name: amount, in: query, required: false, schema: { type: string }, description: "Micro-units (1 USDC = 1000000). Optional." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: { type: string, enum: [solana, base, arbitrum] }
                  to: { type: string, enum: [solana, base, arbitrum] }
                  token: { type: string, example: USDC }
                  mode_default: { type: string, example: fast }
                  modes: { type: object, description: "fast/standard: circle_fee_bps + estimated time" }
                  fee: { type: object, description: "tier, flat_usd, percent, minimum_usd" }
                  amount_usd: { type: string, description: "Present when `amount` was sent." }
                  our_fee_usd: { type: string }
                  our_fee_breakdown: { type: object, description: "flat_usd + percent_usd" }
                  network_fee_usd: { type: string, description: "Circle's fast fee on this amount — passed through, not marked up." }
                  recipient_receives_usd: { type: string }
                  below_minimum: { type: boolean, description: "Present (true) when amount < minimum — the future POST refuses it." }
              example:
                from: base
                to: solana
                token: USDC
                mode_default: fast
                modes: { fast: { circle_fee_bps: 1.3, estimated: "8-20s" }, standard: { circle_fee_bps: 0, estimated: "~13-19min" } }
                fee: { tier: free, flat_usd: "2.000000", percent: "0.30%", minimum_usd: "10.000000" }
                amount_usd: "100.000000"
                our_fee_usd: "2.300000"
                our_fee_breakdown: { flat_usd: "2.000000", percent_usd: "0.300000" }
                network_fee_usd: "0.013000"
                recipient_receives_usd: "97.687000"
        "400": { description: "`invalid_network`, `same_network` or `invalid_amount`." }
        "503": { description: "`route_fees_unavailable` — Circle's fee API unreachable; retry." }

  /bridge:
    post:
      tags: [Move funds]
      summary: Bridge — move USDC from your hotwallet to another network
      description: |
        **One endpoint, routed rails**: you ask to move value between
        networks; the router picks the rail and reports it back as
        metadata (`rail`). Today: **USDC across solana, base and arbitrum
        via CCTP v2, fast mode** (8-20s) — all 6 routes. Coming on the same
        endpoint: USDT (Legacy Mesh) and cross-asset routes.

        Funds leave your **hotwallet** on `from` (children must be swept
        first, as always) and the mint lands **directly on the recipient**
        on `to` — your own hotwallet on the destination network by default
        (rebalancing), or any third-party address via `to_address`
        (cross-chain payout). On Solana the mint targets the recipient's
        USDC token account, created automatically if missing.

        Fee: **$2 flat + a % of the amount** (0.30% free / 0.20% pro),
        **minimum $10 per operation**. Circle's fast fee (1-1.4 bps) is a
        pass-through shown as `network_fee_usd`. Same asset on both sides —
        a network move, not a swap.

        The call returns `accepted` with the burn transaction; confirmation
        arrives via the **`bridge.confirmed` webhook** and
        `GET /v1/operations` (`pending → confirmed`, the optimistic-sweep
        pattern). Send `idempotency_key` — a retry replays the outcome
        instead of bridging twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to, amount, wallet_secret]
              properties:
                from: { type: string, enum: [solana, base, arbitrum] }
                to: { type: string, enum: [solana, base, arbitrum] }
                token: { type: string, enum: [USDC], description: "Optional; USDC is the only routed pair today." }
                amount: { type: string, description: "Micro-units (1 USDC = 1000000). Minimum $10." }
                to_address: { type: string, description: "Defaults to your own hotwallet address on `to`." }
                wallet_secret: { type: string, example: "ws_..." }
                idempotency_key: { type: string }
                external_ref: { type: string, maxLength: 128, description: "The end customer this bridge is for (your own id, as on the child). Travels on bridge.* webhooks and routes their Telegram alert." }
            example:
              from: base
              to: arbitrum
              amount: "50000000"
              wallet_secret: "ws_9f3c..."
              idempotency_key: "rebalance-2026-08-20-01"
      responses:
        "200":
          description: Accepted — burn landed, mint pending
          content:
            application/json:
              example:
                status: accepted
                operation_id: 31
                rail: cctp
                mode: fast
                from: base
                to: arbitrum
                token: USDC
                recipient: "0xfe79...06f5"
                burn_tx: "0xabc..."
                requested_amount: "50000000"
                recipient_receives_estimate: "47.843500"
                our_fee_usd: "2.150000"
                network_fee_usd: "0.006500"
                estimated: "8-20s"
                confirmation: "webhook bridge.confirmed + GET /v1/operations"
        "400": { description: "`same_network`, `below_minimum` (carries `minimum`), `insufficient_balance`, `invalid_recipient`, `no_hotwallet`." }
        "401": { description: "`invalid_wallet_secret`." }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/WalletSecretLocked" }
        "409": { description: "`op_in_progress` — another operation holds this hotwallet." }
        "502": { description: "`bridge_failed` (with `stage: burn` when the pull landed but the burn did not — the ledger records it). A burn that was sent but not yet seen on-chain is answered `accepted` and settles through `bridge.confirmed`." }
        "503": { description: "`sponsor_out_of_gas`, `relayer_not_configured` or `route_fees_unavailable`." }

  /sweep:
    post:
      tags: [Move funds]
      summary: Sweep child balances into the hotwallet
      description: |
        Consolidates children of one token into the hotwallet, in batch. By default
        it scans the 20 most recent children; pass `child_addresses` to target
        specific ones. We sponsor the gas; the fee is taken in-transaction.

        TRON is **optimistic** — each child returns `status:"accepted"` + `trace_id`
        immediately (~1-2s), confirmed seconds later via webhook `sweep.confirmed`
        and `GET /operations` flipping pending→confirmed. Solana, gram(ton), Base and
        Arbitrum return `confirmed` + `tx_hash` synchronously. Children below the
        sweep minimum come back as `skipped: below_min_sweep`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [network, token, wallet_secret]
              properties:
                network: { $ref: "#/components/schemas/Network" }
                token: { $ref: "#/components/schemas/Token" }
                wallet_secret: { type: string, example: "ws_..." }
                child_addresses:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { type: string }
                  description: Specific children to sweep; omit to scan the 20 most recent.
                idempotency_key:
                  type: string
                  maxLength: 64
                  description: |
                    Your own de-duplication tag. Retrying with the same key returns
                    the ORIGINAL batch and its results with `replayed: true` instead
                    of sweeping a second time — safe to retry after a timeout.
                    Added 2026-08-01; before that a timeout+retry could move funds
                    twice. Scoped to your account, so pick whatever you like.
            example:
              network: solana
              token: USDT
              wallet_secret: "ws_9f3c..."
              idempotency_key: "sweep-2026-08-01-batch-7"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  token: { $ref: "#/components/schemas/Token" }
                  hotwallet: { type: string }
                  batch_id: { type: string }
                  requested_at: { type: string, format: date-time }
                  replayed:
                    type: boolean
                    description: |
                      Present and true when this response is a replay of an earlier
                      call with the same `idempotency_key`. Nothing moved this time.
                  results:
                    type: array
                    items:
                      type: object
                      description: Per-child result — confirmed/accepted, or skipped with a reason.
              example:
                network: solana
                token: USDT
                hotwallet: "yM8mdmDynPkdve8XTsiZL6fbK5rYfrCLR1sGkvNLE2q"
                batch_id: "swp_a1b2c3"
                requested_at: "2026-07-17T14:03:00Z"
                results:
                  - { child: "ANjK0xL9...ktZ", swept: "12500000", tx_hash: "3xR...", status: "confirmed" }
                  - { child: "ANjK1xL9...ktZ", skipped: true, reason: "below_min_sweep", balance: "40000" }
        "401": { description: "invalid_wallet_secret" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/WalletSecretLocked" }
        "400": { $ref: "#/components/responses/NoHotwallet" }

  /hotwallet/withdraw:
    post:
      tags: [Move funds]
      summary: Withdraw from the hotwallet to any address
      description: |
        Synchronous in the common case: the response carries `operation_id`,
        `tx_hash` and `status: confirmed` once the transfer has landed. The
        same facts are also pushed as a `withdraw.confirmed` webhook (or
        `withdraw.failed` when the rail refuses after validation) to every
        endpoint subscribed to it — see `POST /webhooks`. Reconcile with
        `GET /operations`.

        **202 pending.** If the transfer was already handed to the network but
        its landing was not seen in time (slow RPC, relayer queue), the call
        returns `202` with `status: pending`, `operation_id` and the `tx_hash`
        (TRON: `trace_id`) when known. It is never reported as failed at that
        point. The final outcome arrives later as `withdraw.confirmed` or
        `withdraw.failed` (`reason: not_landed` / `onchain_reverted` /
        `onchain_error` / `gasfree_failed`), usually within a minute.
        Retrying with the same `idempotency_key` returns the same operation
        and never sends twice. After a `withdraw.failed` nothing left the
        hotwallet and the same key may be used again.

        **TRON queues.** The GasFree rail allows ONE pending transfer per
        hotwallet. Firing the next withdraw seconds after the previous one is
        fine — the API waits for the previous transfer to clear (up to ~25s)
        before submitting. If it still cannot, the call fails with
        `502 withdraw_failed` / `reason: previous_transfer_pending` (and the
        `withdraw.failed` webhook carries the same reason): nothing was sent,
        retry with the same `idempotency_key` a few seconds later.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [network, token, to, amount, wallet_secret]
              properties:
                network: { $ref: "#/components/schemas/Network" }
                token: { $ref: "#/components/schemas/Token" }
                to: { type: string, description: Destination address. }
                amount: { type: string, description: Gross amount in micro-units (6 decimals). Recipient receives amount − fee. }
                wallet_secret: { type: string, example: "ws_..." }
                allow_partial:
                  type: boolean
                  default: false
                  description: "TRON only. A payout larger than the hotwallet can send is refused with 400 `insufficient_balance` (balance, available, reserved_for_fees). Pass true to send what fits instead; the response and the webhook then carry `recipient_receives` and `capped_for_fees: true`."
                whatsapp:
                  type: object
                  description: "WhatsApp for this payout's end customer (needs external_ref). Upserts the contact with your opt-in, then withdraw.* reaches that phone. Paid per delivered message — see GET /alerts/whatsapp."
                  required: [phone, opt_in]
                  properties:
                    phone: { type: string, description: E.164. }
                    opt_in: { type: boolean, enum: [true] }
                    name: { type: string }
                    lang: { type: string, enum: [en, pt, es] }
                external_ref: { type: string, maxLength: 128, description: "The end customer this payout is for (your own id, as on the child). Travels on withdraw.* webhooks and routes their Telegram alert." }
                idempotency_key:
                  type: string
                  maxLength: 64
                  description: |
                    Your own de-duplication tag. Retrying with the same key returns
                    the ORIGINAL withdrawal with `replayed: true` (and its current
                    `status`, which may be `pending`) instead of sending a second
                    time — safe to retry after a timeout. The key is reserved before
                    anything is sent, so two concurrent calls with it produce one
                    transfer. Added 2026-08-01.
            example:
              network: solana
              token: USDT
              to: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin"
              amount: "10000000"
              wallet_secret: "ws_9f3c..."
              idempotency_key: "payout-inv-10432"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  token: { $ref: "#/components/schemas/Token" }
                  operation_id: { type: integer }
                  tx_hash: { type: string }
                  status: { type: string, enum: [confirmed] }
                  recipient_receives: { type: string, description: amount − fee, in micro-units. }
                  fee_usdt: { type: string }
              example:
                network: solana
                token: USDT
                operation_id: 412
                tx_hash: "5rk3...9Za"
                status: "confirmed"
                recipient_receives: "8000000"
                fee_usdt: "2.00"
        "202":
          description: "Sent to the network, landing not yet seen. Final outcome follows as withdraw.confirmed or withdraw.failed."
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { $ref: "#/components/schemas/Network" }
                  token: { $ref: "#/components/schemas/Token" }
                  operation_id: { type: integer }
                  tx_hash: { type: string, nullable: true }
                  trace_id: { type: string, description: TRON GasFree only. }
                  status: { type: string, enum: [pending] }
                  to: { type: string }
                  requested_amount: { type: string }
                  recipient_receives: { type: string }
              example:
                network: base
                token: USDC
                operation_id: 413
                tx_hash: "0x8f2c...41"
                status: "pending"
                to: "0x5b3e...9a"
                requested_amount: "10000000"
                recipient_receives: "9800000"
        "400": { description: "amount_too_small · insufficient_balance · invalid_recipient · amount_below_fee · unsupported_token_network_pair" }
        "401": { description: "invalid_wallet_secret" }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "429": { $ref: "#/components/responses/WalletSecretLocked" }
        "402": { description: "reserved_for_fees — balance only covers accrued fees owed on this hotwallet" }
        "409": { description: "op_in_progress — another sweep/withdraw is running for this address" }
        "422": { description: "sponsor_refused (gas floor / rule) — Solana rail" }
        "503": { description: "sponsor_out_of_gas / sponsor_not_configured / gasfree_unavailable" }

  /operations:
    get:
      tags: [Move funds]
      summary: Fee ledger (sweeps + withdraws)
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: batch_id, in: query, schema: { type: string } }
      responses:
        "200": { description: Itemized operations with per-op fee and network fee breakdown. }

  /ledger:
    get:
      tags: [Move funds]
      summary: Unified on-chain statement (deposits + operations)
      description: >
        One feed with every on-chain movement of your account: deposits
        (free) merged with sweeps/withdraws/fee settlements. Fees are
        itemized: `fee_usd` (dolaramp) and `network_fee_usd` — on TRON the
        network charges 1.5 USDT per transfer plus `activation_usd` (1.5,
        once) when a virgin account is activated.
      parameters:
        - { name: day, in: query, schema: { type: string, example: "2026-07-22" }, description: "YYYY-MM-DD (UTC)." }
        - { name: network, in: query, schema: { type: string, enum: [solana, tron, ton, base, arbitrum] } }
        - { name: token, in: query, schema: { type: string, enum: [USDT, USDC, PYUSD] } }
        - { name: kind, in: query, schema: { type: string, enum: [deposit, sweep, withdraw, fee_collection] } }
        - { name: sort, in: query, schema: { type: string, enum: [date_desc, amount_desc, amount_asc], default: date_desc } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string }
                        datetime: { type: string }
                        network: { type: string }
                        token: { type: string, nullable: true }
                        amount_usd: { type: number }
                        fee_usd: { type: number, description: "dolaramp fee" }
                        network_fee_usd: { type: number, description: "charged by the network inside the tx (TRON gasless rail)" }
                        activation_usd: { type: number, description: "one-time TRON account activation included in network_fee_usd" }
                        tx_hash: { type: string, nullable: true }
                        ref: { type: string, nullable: true, description: "child external_ref, child address or destination" }
                        batch_id: { type: string, nullable: true }
                        status: { type: string }
                  has_more: { type: boolean }
                  offset: { type: integer }
                  limit: { type: integer }
        "400": { description: "bad_day (use YYYY-MM-DD)" }

  /statement:
    get:
      tags: [Move funds]
      summary: Monthly statement (accounting export)
      parameters:
        - { name: month, in: query, required: true, schema: { type: string, example: "2026-07" }, description: "YYYY-MM (UTC)." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  period: { type: object }
                  summary: { type: object, description: "deposits, sweeps, withdraws, fees_total, network_fees_total (exact)." }
                  addresses: { type: object }
                  deposits: { type: array, items: { type: object } }
                  operations: { type: array, items: { type: object } }
        "400": { description: "invalid_month (use YYYY-MM)" }

  /deposits:
    get:
      tags: [Receive]
      summary: Detected deposits (into child wallets)
      description: |
        **Two states, and the difference is money.** A deposit is recorded the
        instant we see the transfer on-chain — that is `arriving`, and it means
        exactly "a transaction exists", not "it cannot be undone". It becomes
        `confirmed` when that network's own requirement is met (TRON
        solidification, Base/Arbitrum safe head, Solana finalized; gram(TON) is final on
        inclusion).

        **Release goods on `confirmed`, never on `arriving`.** An arriving
        transfer can still disappear — on an L2 with a single sequencer this is
        not theoretical. The `deposit.confirmed` webhook fires only at
        confirmation, which is why it is the one to build on; subscribe to
        `deposit.detected` if you want to show your user "payment on its way".
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deposits:
                    type: array
                    items:
                      type: object
                      properties:
                        network: { $ref: "#/components/schemas/Network" }
                        token: { $ref: "#/components/schemas/Token" }
                        address: { type: string }
                        external_ref: { type: string }
                        amount: { type: string, description: "Micro-units of the token, as a string. Use this for accounting." }
                        amount_usd: { type: string }
                        tx_hash: { type: string, description: "With `address` and `token`, the key to de-duplicate against the webhook." }
                        from: { type: string }
                        detected_at: { type: string, format: date-time, description: When we first saw the transfer. }
                        confirmed_at:
                          type: [string, "null"]
                          format: date-time
                          description: When the network's requirement was met. `null` while arriving.
                        state:
                          type: string
                          enum: [arriving, confirmed, failed]
                          description: |
                            `arriving` — seen, not yet irreversible. Do not release goods.
                            `confirmed` — settled by this network's rule. Safe to act on.
                            `failed` — never settled within the deadline; treat as not received.
              example:
                deposits:
                  - network: base
                    token: USDC
                    address: "0x9f3c1e8a2b4d6f8a0c2e4a6b8d0f2a4c6e8b0d2f"
                    external_ref: "order-10432"
                    amount: "25000000"
                    amount_usd: "25.00"
                    tx_hash: "0x7c1f0b6e2d9a4c8e5f3a1b0d9c8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e"
                    from: "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"
                    detected_at: "2026-08-01T12:00:01Z"
                    confirmed_at: "2026-08-01T12:01:02Z"
                    state: confirmed
                  - network: tron
                    token: USDT
                    address: "TRdZNZ2aVC7WtcwXeMLtnfafXAEueQXVwN"
                    amount: "10000000"
                    amount_usd: "10.00"
                    tx_hash: "3f9a1c7e5b2d8f4a6c0e9b7d5f3a1c8e6b4d2f0a9c7e5b3d1f8a6c4e2b0d9f7a"
                    from: "TN1jVF7pRMBpwstAtvKTeF9AqxLxpZ8dJk"
                    detected_at: "2026-08-01T12:03:00Z"
                    confirmed_at: null
                    state: arriving

  /webhooks:
    post:
      tags: [Receive]
      summary: Register a webhook endpoint
      description: |
        Up to 5 endpoints per account. The `secret` is returned **once**, here.

        **What we send.** A `POST` with `Content-Type: application/json` and the
        body `{ "event": "<name>", "data": { ... } }`. Answer any `2xx` within
        8 seconds. Redirects are not followed: a `3xx` counts as a failure.

        **Headers on every delivery.**

        | header | value |
        |---|---|
        | `X-DolaRamp-Signature` | hex HMAC-SHA256 of the raw body, keyed with your `secret` |
        | `X-DolaRamp-Signature-V1` | `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">` |
        | `X-DolaRamp-Event` | the event name, same as `event` in the body |
        | `X-DolaRamp-Delivery-Id` | id of this delivery, stable across retries: use it to de-duplicate |
        | `X-DolaRamp-Attempt` | `1` on the first try, up to `8` |

        **How to verify.** Compute the HMAC over the bytes you received, before
        parsing the JSON, and compare in constant time:

        ```js
        import { createHmac, timingSafeEqual } from 'node:crypto';

        function verify(rawBody, headers, secret) {
          const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
          const got = headers['x-dolaramp-signature'] ?? '';
          const a = Buffer.from(expected);
          const b = Buffer.from(got);
          return a.length === b.length && timingSafeEqual(a, b);
        }
        ```

        `X-DolaRamp-Signature-V1` signs the timestamp together with the body.
        Use it if you want to refuse replays: recompute
        `HMAC(secret, t + "." + rawBody)`, compare with `v1`, and reject a `t`
        older than the tolerance you choose (5 minutes is common).

        **Retries.** A delivery that fails (timeout, connection error, non-2xx)
        is retried up to **8 attempts** in total, waiting 30 s after the first
        failure and doubling each time (30 s, 1 min, 2 min, 4 min, 8 min,
        16 min, 32 min). After the last attempt it is marked `failed`;
        `GET /webhooks/deliveries` shows every attempt and its error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri, description: HTTPS only. }
                events:
                  type: array
                  items:
                    type: string
                    enum: [deposit.detected, deposit.confirmed, deposit.abandoned, sweep.confirmed, sweep.failed, bridge.confirmed, bridge.failed, withdraw.confirmed, withdraw.failed, tx.confirmed, tx.failed]
                  description: |
                    `deposit.detected` — a transfer was seen on-chain. Informational:
                    show "payment on its way", do NOT release goods.
                    `deposit.confirmed` — settled by that network's rule. **This is the
                    one to act on.** Deposit payloads carry the child's `external_ref`
                    (since 2026-09-05), so no lookup is needed to credit your user.
                    `deposit.abandoned` — seen but never settled within the deadline;
                    treat as not received.
                    `sweep.confirmed` / `sweep.failed` — a child → hotwallet sweep settled or failed.
                    `bridge.confirmed` / `bridge.failed` — a cross-network bridge settled or failed.
                    `withdraw.confirmed` — a `POST /hotwallet/withdraw` left the hotwallet.
                    `data` carries `operation_id`, `tx_hash`, `status: confirmed`, `to`,
                    `requested_amount`, `recipient_receives`, `fee_usdt` and your
                    `idempotency_key`. Fires once, when the transfer has landed: right
                    away for a synchronous 200, or later for a withdraw answered 202.
                    Same facts as the synchronous response — for queues that want one
                    inbound source of truth.
                    `withdraw.failed` — the rail refused or errored after validation
                    (`reason`), a 202 withdraw did not land (`operation_id`,
                    `reason: not_landed` / `onchain_reverted` / `onchain_error` /
                    `gasfree_failed`), or the hotwallet could not cover the request
                    (`reason: insufficient_balance`, with `balance_usd`, `available_usd`
                    and, on TRON, `reserved_usd`). Other input errors (4xx) are answered
                    inline only.
                    `tx.confirmed` / `tx.failed` — legacy names for the two withdraw
                    events; an endpoint subscribed to them receives `withdraw.*`.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  secret: { type: string, description: "HMAC signing secret — shown once." }
    get:
      tags: [Receive]
      summary: List webhook endpoints
      responses:
        "200": { description: OK }

  /webhooks/{id}:
    delete:
      tags: [Receive]
      summary: Remove a webhook endpoint
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Removed }

  /webhooks/deliveries:
    get:
      tags: [Receive]
      summary: Delivery log
      description: Every delivery attempt with HTTP status, retry count and success flag.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items:
                      type: object
                      properties:
                        event: { type: string }
                        url: { type: string }
                        status_code: { type: integer, nullable: true }
                        attempts: { type: integer }
                        success: { type: boolean }
                        created_at: { type: string, format: date-time }

  /alerts/telegram:
    get:
      tags: [Receive]
      summary: Telegram alert channels
      description: |
        The HUMAN side of notifications: every deposit, sweep, withdrawal and
        bridge on your account, as a message in a Telegram chat — yours or your
        ops group. Free on every plan. Webhooks are for your system; this is for
        your people. Events below a per-chat minimum are skipped; failures
        always arrive. `deposit.detected` (the early "on its way" signal) is
        sent on pro and enterprise.

        **Your END CUSTOMER can be alerted too** (`kind: customer`): every
        child created with an `external_ref` returns a `telegram_url`. Show it
        next to the deposit address; whoever opens it is bound to that
        `external_ref` and receives deposit / withdraw / bridge messages
        about their own money only — rendered from your `/alerts/profile`,
        in the person's own Telegram language, no plan gate. Withdraw and
        bridge calls accept `external_ref` to reach the same person. The
        response lists your team chats in `channels` and a count plus the
        latest ten customer chats in `customers`.

        **Delivery limits.** Telegram allows about one message per second
        per chat and ~30 per second per bot. Alerts are queued, never sent
        on the critical path: one queue per chat (1.2 s apart, at most 40
        waiting — beyond that the oldest burst is dropped and logged) under
        a platform-wide budget of 20 messages per second shared by every
        account. A burst of a thousand deposits therefore arrives over a
        minute or so, in order, rather than getting the bot rate-limited.
        Your webhook is the system of record; the chat is a courtesy — use
        `min_usd` on team chats to keep them readable.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  configured: { type: boolean, description: Whether the bot is enabled on this server. }
                  bot: { type: string, nullable: true, description: The bot's username. }
                  events: { type: array, items: { type: string } }
                  pro_only_events: { type: array, items: { type: string } }
                  limit: { type: integer, description: Max chats per account. }
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        pending: { type: boolean, description: "true until someone sends /start with the code" }
                        chat_title: { type: string, nullable: true }
                        chat_type: { type: string, nullable: true, enum: [private, group, supergroup] }
                        lang: { type: string, enum: [en, pt, es] }
                        events: { type: array, items: { type: string } }
                        min_usd: { type: string, example: "25.00" }
                        active: { type: boolean }
                        linked_at: { type: string, format: date-time, nullable: true }
                        expires_at: { type: string, format: date-time, nullable: true, description: Pending codes only. }

  /alerts/profile:
    get:
      tags: [Receive]
      summary: End-customer alert profile
      description: |
        What YOUR customer sees in a Telegram alert. The sentence is ours
        ("<name> has confirmed your deposit of $100.00 USDC"), the blanks are
        yours: display name, support contacts, a button back into your app
        (`{ref}` → the customer's `external_ref`, URL-encoded) and one optional
        line of your own. Plain text only — every field is escaped.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertProfile" }
    put:
      tags: [Receive]
      summary: Set the end-customer alert profile
      description: Partial — only the fields sent change; `null` clears one.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AlertProfile" }
      responses:
        "200":
          description: The saved profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlertProfile" }

  /alerts/telegram/customer-link:
    get:
      tags: [Receive]
      summary: Telegram link for one end customer (no deposit address needed)
      description: |
        For payout-only integrations — a hotwallet and a withdraw queue, no
        children. Returns a permanent `t.me` link bound to your `external_ref`
        (a user id, an invoice). Show it in your app as "get notified on
        Telegram"; whoever opens it and presses Start receives withdraw and
        bridge messages for that reference — pass the same `external_ref` on
        `POST /hotwallet/withdraw` and `POST /bridge`. Deterministic: the same
        ref always gets the same link. `name` sets how the person is greeted.
        Creates or updates the channel record, so it needs an `operate` key
        even though it is a GET. Up to 5000 customer channels per account.
      parameters:
        - { name: external_ref, in: query, required: true, schema: { type: string, maxLength: 128 } }
        - { name: name, in: query, required: false, schema: { type: string, maxLength: 128 }, description: "Greeting name (\"Hello, Ana!\")." }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  external_ref: { type: string }
                  url: { type: string, example: "https://t.me/dolaramp_bot?start=r3f1c9a2b7d4e6f8a1b2c3d4" }
                  command: { type: string }
                  linked: { type: boolean, description: Someone already pressed Start on it. }
                  active: { type: boolean }
        "403": { $ref: "#/components/responses/InsufficientScope" }
        "409": { description: "`channel_limit_reached` — 5000 customer channels on this account." }
        "503": { description: "`telegram_not_configured`" }

  /alerts/whatsapp:
    get:
      tags: [Receive]
      summary: WhatsApp alerts to your end customer (paid)
      description: |
        The same end-customer alerts on WhatsApp, sent from the DolaRamp
        number with your `/alerts/profile` name in the text, using
        Meta-approved utility templates only (no free text). **US$ 0.10 per
        delivered message**, booked as a `notification` operation and
        collected with your next fee on that network (in the same tx on
        Solana / EVM / TON, in the $50 batch on TRON). Failed messages are
        not charged. Requires the person's opt-in, which you record on the
        contact. Returns this month's counts and cost, unpaid balance,
        contacts, and the latest 20 messages.
      responses:
        "200": { description: OK }

  /alerts/whatsapp/contacts:
    put:
      tags: [Receive]
      summary: Bind a WhatsApp number to one external_ref (opt-in required)
      description: |
        Upsert. From then on every deposit / withdraw / bridge event carrying
        that `external_ref` reaches the phone. `opt_in` must be `true` — you
        are asserting the person agreed to receive these; we store when.
        Meta's policy bans senders over unsolicited messages, so a contact
        without real consent puts everyone's channel at risk.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [external_ref, phone, opt_in]
              properties:
                external_ref: { type: string, maxLength: 128 }
                phone: { type: string, example: "+5548999990000", description: E.164. }
                opt_in: { type: boolean, enum: [true] }
                name: { type: string, maxLength: 128 }
                lang: { type: string, enum: [en, pt, es], default: pt }
      responses:
        "200": { description: The contact }
        "400": { description: "`invalid_phone`" }
        "503": { description: "`whatsapp_not_configured`" }

  /alerts/whatsapp/contacts/{external_ref}:
    delete:
      tags: [Receive]
      summary: Remove a WhatsApp contact (opt-out)
      parameters:
        - { name: external_ref, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Removed }

  /alerts/telegram/link:
    post:
      tags: [Receive]
      summary: Connect a Telegram chat
      description: |
        Issues a one-time code (valid 10 minutes) and a `t.me` deep link. Open
        the link, or send `/start <code>` to the bot from any chat — a private
        one or a group the bot was added to. The chat that sends the code is
        the chat that gets linked; nothing else can choose it. The code is
        shown once and stored hashed. `/stop` in the chat turns it off,
        `/status` says what it receives.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                lang: { type: string, enum: [en, pt, es], default: en, description: Language of the messages. }
                events:
                  type: array
                  items: { type: string, enum: [deposit.detected, deposit.confirmed, deposit.abandoned, sweep.confirmed, sweep.failed, withdraw.confirmed, withdraw.failed, bridge.confirmed, bridge.failed, hotwallet.low] }
                  description: Defaults to every event the free tier allows. `hotwallet.low` is Telegram-only (no webhook) — the free balance after a withdraw dropped under the platform floor.
                min_usd: { type: number, minimum: 0, default: 0, description: Skip money events below this. 0 = everything. }
      responses:
        "201":
          description: Code issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: integer }
                  code: { type: string, example: "K7PQ2XW9MB" }
                  url: { type: string, example: "https://t.me/dolaramp_bot?start=K7PQ2XW9MB" }
                  command: { type: string, example: "/start K7PQ2XW9MB" }
                  bot: { type: string }
                  expires_at: { type: string, format: date-time }
        "409": { description: "`channel_limit_reached` — remove a chat first." }
        "503": { description: "`telegram_not_configured` — the bot is not enabled on this server." }

  /alerts/telegram/{id}:
    patch:
      tags: [Receive]
      summary: Edit a Telegram chat
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                events: { type: array, items: { type: string } }
                min_usd: { type: number, minimum: 0 }
                active: { type: boolean, description: "false silences the chat without removing it" }
                lang: { type: string, enum: [en, pt, es] }
      responses:
        "200": { description: The updated channel }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Receive]
      summary: Remove a Telegram chat
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Removed }

  /balance/{network}/{address}/{token}:
    get:
      tags: [Check]
      summary: Live on-chain balance
      parameters:
        - { name: network, in: path, required: true, schema: { $ref: "#/components/schemas/Network" } }
        - { name: address, in: path, required: true, schema: { type: string } }
        - { name: token, in: path, required: true, schema: { $ref: "#/components/schemas/Token" } }
      responses:
        "200": { description: OK }

  /address/validate:
    post:
      tags: [Check]
      summary: Validate a destination address
      description: Per-network checksum. Validate before withdrawing so a typo never leaves your hotwallet.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, network]
              properties:
                address: { type: string }
                network: { type: string, enum: [solana, tron, ton, base, arbitrum], description: "EVM networks (base/arbitrum): 0x format check (an EVM address is valid on every EVM chain; no EIP-55 checksum applied)." }
            example: { address: "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin", network: solana }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  reason: { type: string, nullable: true }

  /address/detect:
    post:
      tags: [Check]
      summary: Detect which network an address belongs to
      description: |
        Infers the network from the address format. `supported:false` flags an
        address on a chain we don't handle, so funds aren't sent to the wrong rail.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address: { type: string }
            example: { address: "0x059F89Be335119daf7E94870C76690B40cEfb412" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  network: { type: string, description: "solana | tron | ton | base | unknown — a 0x address answers `base` but is valid on every EVM chain (arbitrum included); the caller picks the concrete network." }
                  supported: { type: boolean }
                  supported_networks: { type: array, items: { $ref: "#/components/schemas/Network" } }

  /rates:
    get:
      tags: [Check]
      summary: USDT/fiat market rates
      description: |
        Mid-market reference (USDT/fiat on real exchanges). **Not a swap** — apply
        your own spread: quote above when you sell USDT, below when you buy.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  fiats: { type: array, items: { type: object } }
                  rates:
                    type: array
                    items:
                      type: object
                      properties:
                        fiat: { type: string, example: BRL }
                        rate: { type: number, example: 5.13 }
                        change_24h_pct: { type: number, nullable: true }
                  disclaimer: { type: string }

  /rates/{fiat}:
    get:
      tags: [Check]
      summary: One fiat rate
      parameters:
        - { name: fiat, in: path, required: true, schema: { type: string, example: BRL } }
      responses:
        "200": { description: OK }
        "404": { description: unsupported_fiat }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: "Your API key: `dr_live_...`. Created on signup; manage in the panel."
  schemas:
    Network:
      type: string
      enum: [solana, tron, ton, base, arbitrum]
      description: "`ton` is branded gram(ton) in the product."
    Token:
      type: string
      enum: [USDT, USDC, PYUSD]
      description: "Solana: USDT/USDC/PYUSD · TRON/gram(ton): USDT · Base: USDC · Arbitrum: USDC + USDT (as USDT0, Tether's omnichain deployment)."
    SecretBody:
      type: object
      required: [wallet_secret]
      properties:
        wallet_secret: { type: string, example: "ws_..." }
    AlertProfile:
      type: object
      properties:
        display_name: { type: string, nullable: true, maxLength: 128, description: Your name in the message. Defaults to your account name. }
        support_phone: { type: string, nullable: true, maxLength: 64 }
        support_email: { type: string, nullable: true, format: email }
        support_url: { type: string, nullable: true, format: uri, description: HTTPS only. }
        return_url: { type: string, nullable: true, format: uri, description: "Button under every alert. HTTPS only; `{ref}` is replaced by the customer's external_ref." }
        return_label: { type: string, nullable: true, maxLength: 64, description: Defaults to "back" in the customer's language. }
        extra_line: { type: string, nullable: true, maxLength: 300, description: One sentence of yours, appended to every customer alert. }
        hotwallet_low_usd: { type: number, nullable: true, description: "Floor for the Telegram-only `hotwallet.low` team alert. null = platform default (`hotwallet_low_default_usd`, read-only). Also settable with /piso in a team chat." }
    Child:
      type: object
      properties:
        address: { type: string }
        deposit_address: { type: string, description: What to hand your user (TRON = GasFree account). }
        external_ref: { type: string, nullable: true }
        telegram_url: { type: string, nullable: true, description: "Deep link that lets YOUR customer get Telegram alerts about this address (null without external_ref or when the bot is off). See GET /alerts/telegram." }
        balance_usd: { type: string, nullable: true, description: Present when with_balance=1. }
        created_at: { type: string, format: date-time }
    Error:
      type: object
      properties:
        error: { type: string }
        reason: { type: string }
  responses:
    InsufficientScope:
      description: "`insufficient_scope` — this key is `read`; the call needs an `operate` key."
    WalletSecretLocked:
      description: "`wallet_secret_locked` — too many wrong `wallet_secret` attempts for this account; retry after `retry_in_s`."
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: "`not_found` — no such resource on this account."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    GasFreeUnavailable:
      description: "`gasfree_unavailable` — TRON only. The gasless address could not be resolved right now. Nothing was created; repeat the same call."
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  retry: { type: boolean, description: "`true`: the same call can be repeated as is." }
          example: { error: service_unavailable, reason: gasfree_unavailable, retry: true }
    NoHotwallet:
      description: No hotwallet on this network — create one first.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    AddressLimit:
      description: Address cap for your plan reached — upgrade or reuse children.
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  tier: { type: string }
                  limit: { type: integer }
                  current: { type: integer }
