openapi: 3.1.0

info:
  title: Horizon Market — Atomic Swap API
  version: "1.0.0"
  # Rendered in the Redoc sidebar at /openapi. Absolute URL so external viewers
  # (Redoc/Swagger loading the spec from another origin) resolve it too;
  # backgroundColor matches the dark sidebar in src/app/openapi/route.ts.
  x-logo:
    url: https://horizon.market/logo-horizon.png
    altText: Horizon Market
    href: https://horizon.market
    backgroundColor: "#0B0B15"
  description: |
    > **Recommended: use the official client library.** The fastest way to build
    > on this API is
    > **[`@unspendablelabs/horizon-market-client`](https://www.npmjs.com/package/@unspendablelabs/horizon-market-client)**
    > — an open-source TypeScript SDK with drop-in React / React Native components
    > and a `horizon` CLI. It wraps the whole quote → sign → submit lifecycle into a
    > few method calls, runs in Node, the browser and React Native, and keeps your
    > private key on the client (`npm install @unspendablelabs/horizon-market-client`).
    > The reference below documents the raw HTTP surface, for when you need
    > lower-level control or are working outside JavaScript.

    Public REST API for opening, listing and filling **atomic swap** orders on
    Bitcoin. Four asset types are supported:

    | `listing_type` | Asset family                       |
    | -------------- | ---------------------------------- |
    | `counterparty` | Counterparty assets                |
    | `ordinal`      | Bitcoin Ordinals / inscriptions    |
    | `zeld`         | Zeld assets                        |
    | `kontor`       | Kontor KOR token and Kontor NFTs   |

    All trade-critical state changes are authorised by client-side cryptographic
    signatures — either a **signed PSBT** (Partially Signed Bitcoin Transaction)
    or a **BIP322** message signature. The client always holds its private key;
    the server never sees it. As a result most write endpoints do not require a
    session: the signature *is* the authentication.

    Optional account endpoints let a client request an email OTP or wallet
    challenge if it wants to attach a user profile to its swaps.

    ## Accounts, credits & the platform fee

    A session is **never** required to trade — the on-chain signature is the
    authentication. A session only changes **who pays the platform fee** charged
    when opening a listing. That fee is **waived** whenever the calling session
    has an active **subscription** or at least one **credit** (`credits` +
    `free_credits` > 0). Anonymous callers always pay the fee on-chain.

    **Free credits — 10 per month.** Creating an account grants **10 free
    credits** immediately, and a monthly cron tops every account back up to 10 on
    the **1st of each month** (any balance below 10 is reset to 10 — credits do
    **not** accumulate above the cap). To obtain and use them:

    1. **Request a challenge** — `POST /api/auth/email/challenges` (email OTP) or
       `POST /api/auth/wallet/challenges` (BIP322 wallet). The **first** successful
       verification creates the account and seeds its 10 free credits.
    2. **Verify the challenge.** Cross-origin browser clients call
       `POST /api/auth/wallet/token` (CORS-open) — it verifies the BIP322
       signature and returns an Auth.js session **JWT** in the body (no cookie).
       Same-origin clients may instead use the Auth.js credentials callbacks
       (`POST /api/auth/callback/OTP` / `POST /api/auth/callback/WALLET`, each
       posted with a CSRF token, out of scope here) to obtain the
       `authjs.session-token` cookie.
    3. **Authenticate fee requests** on `sell-quotes` / `POST /api/atomic-swaps`
       with either `Authorization: Bearer <token>` (the JWT from step 2) or the
       session cookie. While credits (or a subscription) remain, `sell-quotes`
       returns `fee_waived: true` / `fee_payment_id: null` and you **omit the fee
       object** on create — no on-chain fee payment. (`fee-quotes` ignores the
       session and never waives, so account holders simply skip it.)

    Each listing opened **without** a subscription **consumes one credit** (free
    credits are spent first, then paid `credits`). When the balance reaches zero
    the platform fee reverts to on-chain payment. Read the remaining balance any
    time from `GET /api/auth/credits` (CORS-open; bearer or cookie). Paid credit
    top-ups and subscriptions are purchased through the web UI, not this API.

    This document describes the **new, REST-clean surface**. Legacy paths
    (`/[id]/read`, `/[id]/multi-buy`, `/on-chain-payment`, `/delist`,
    `/auth/start`, `/auth/start/wallet`) remain available as undocumented
    back-compat shims for the existing web UI.

    ## Conventions

    * **Transport** — `https://horizon.market` (mainnet) or your local dev host.
    * **CORS** — every documented endpoint is fully open to cross-origin browser
      clients (`Access-Control-Allow-Origin: *`) with `OPTIONS` preflight support
      (a preflight returns `204 No Content` with the CORS headers; allowed methods
      are `GET, OPTIONS, POST, PUT, DELETE`; the `Authorization` header is
      allowed, so a bearer token works cross-origin): the atomic-swap surface
      (`/api/atomic-swaps…`), the Bitcoin helpers (`/api/bitcoin/…`), and the auth
      endpoints `/api/auth/email/challenges`, `/api/auth/wallet/challenges`,
      `/api/auth/wallet/token`, and `/api/auth/credits`. Undocumented internal
      routes are not part of this public, cross-origin surface.
    * **Content type** — `application/json` for all request/response bodies.
    * **Casing** — all JSON keys are `snake_case`.
    * **Addresses** — composed PSBTs only support **P2WPKH** (`bc1q…`) and
      **P2TR** (`bc1p…`) addresses. A `seller_address` may be either, but a
      **P2TR seller must also send `seller_pubkey`** (used as the taproot
      internal key). A `buyer_address` must be **P2WPKH**; ordinal buyers
      additionally pass a **P2TR** `buyer_taproot_address` that receives the
      inscription. Legacy / P2SH addresses are rejected with a `400`.
    * **Networks** — `zeld` listings are **mainnet only**. The `zeld` *compose*
      branches of `sell-quotes` / `fee-quotes` return `400` on other networks. The
      ZELD indexer is itself hardcoded to mainnet (there is no signet ZELD index),
      so a `zeld` quote that passes an explicit `asset_utxo_id` (skipping the
      compose guard) is always resolved against **mainnet** ZELD data regardless of
      the host network — off mainnet the seller simply won't hold that UTXO, so it
      resolves to nothing. Treat `zeld` as mainnet-only end to end.
    * **Quote validity** — the `fee_payment_id` returned by `sell-quotes` /
      `fee-quotes` is backed by an on-chain-payment record that **expires 30
      minutes after the quote**. Sign and submit to `POST /api/atomic-swaps`
      within that window; afterwards the create call returns
      `400 Bad request: Invalid OnChainPayment` and you must request a fresh
      quote. When the caller has an active subscription or credits, the platform
      fee is **waived on `sell-quotes`** (`fee_payment_id` is `null`, `fee_waived`
      is `true`) and on **create** (the fee object may be omitted) — no on-chain
      payment is needed. `fee-quotes` itself has **no** waiver path: it always
      reserves an on-chain payment and always returns a non-null `fee_payment_id`
      (there is no `fee_waived` field on its result), so account holders simply
      skip the `fee-quotes` call entirely instead.
    * **Numbers** — Bitcoin amounts are integer **sats**; Counterparty asset
      quantities follow the asset's divisibility (integer for non-divisible,
      integer expressed in satoshi-style smallest units for divisible).
    * **Price & royalties** — `price` is the **net amount the seller receives**,
      in sats: it is exactly the value of the seller-payment output the swap PSBT
      is signed against. For an asset that carries an issuer **royalty**, the
      royalty is **added on top** for the buyer — the buyer pays `price + royalty`
      and `buy-quotes` returns the `royalty_sats` / `royalty_address` it attaches.
      A royalty is **not** deducted from `price`. Royalty outputs below the
      546-sat dust limit are omitted and `royalty_sats` is `0`.
    * **Timestamps** — RFC 3339 UTC strings (e.g. `2025-12-01T18:42:00Z`).
    * **Response envelope** — all 2xx responses wrap the payload in a top-level
      `data` key: `{ "data": <payload> }`. The schemas under each response
      describe the contents of `data` unless noted otherwise.
    * **Request bodies** — endpoints documented here use a **flat** JSON object
      (no `data` wrapper). For backward compatibility with the legacy web UI,
      `POST /api/atomic-swaps/fee-quotes`, `.../sell-quotes`, `.../buy-quotes`
      and `POST /api/atomic-swaps` also accept a single-level wrapped shape
      `{ "data": { …fields } }`. For `POST /api/atomic-swaps` the wrapped variant
      carries the platform fee under a sibling `payment` object with
      **camelCase** keys (`{ "data": {…}, "payment": { "psbtHex": …,
      "feePaymentId": … } }`). These wrapped forms are legacy only — new clients
      should always send the flat shape.
    * **Errors** — non-2xx responses always carry `{ "error": "<reason>" }` with
      an appropriate HTTP status.
    * **Pagination** — offset / limit query params; responses include a
      `pagination` object.
    * **Idempotency** — `POST /atomic-swaps/purchases` is **not** idempotent;
      rebroadcasting a signed tx will be rejected by the network.

    ## Workflows

    The following end-to-end flows tie the endpoints together. The **quote
    endpoints** (`sell-quotes`, `buy-quotes`) compose every PSBT server-side, so a
    client only needs an HTTP client and a Bitcoin signer — no indexer and no
    PSBT-composition library. The recommended flow is therefore **quote → sign →
    submit**. Composing PSBTs yourself remains supported (the "advanced" note at
    the end of each flow) but is optional.

    ### 1. Open a sell order (sell)

    A listing's **swap PSBT** spends the single asset UTXO being sold. That UTXO
    must exist (or be created) before the swap is valid, and the platform fee must
    be paid. Depending on the asset and what the seller already holds, opening a
    listing involves **up to three transactions to sign**:

    * **prep tx** *(when needed)* — creates the dedicated asset UTXO. For `zeld`
      this is a ZELD transfer that isolates the exact amount onto a fresh ~330-sat
      UTXO **and** (when the fee is not waived) pays the platform fee in the same
      tx. For `counterparty` where the asset still sits in the address balance (not
      yet UTXO-attached) this is a Counterparty **attach** tx. Returned as
      `prep_psbt` with `prep_kind`. If a confirmed, non-locked UTXO already holds
      exactly the requested quantity, the server reuses it directly and `prep_psbt`
      is `null`.
    * **fee tx** — the platform-fee payment. A separate PSBT (`fee_psbt`) only when
      listing from an **existing** asset UTXO (`counterparty` / `ordinal` /
      `zeld`). When a prep tx is composed the fee is instead **folded into it** as
      an extra BTC output, so `fee_psbt` is `null` — this applies to both the
      `zeld` transfer and the `counterparty` **attach**.
    * **swap tx** — the asset sale PSBT (`swap_psbt`), which spends the asset UTXO
      the quote reports as `asset_utxo_id` (vout `0` of the prep tx in the common
      case; always use the returned `asset_utxo_id` rather than assuming an index).

    > **Counterparty attach + reveal:** for some taproot envelopes Counterparty
    > returns a pre-signed **reveal** tx. The asset then sits on vout `0` of the
    > **reveal** tx, not the attach commit. In that case `sell-quotes` sets
    > `asset_utxo_id` to `{reveal_txid}:0`, `swap_psbt` spends that UTXO, and
    > `reveal_tx_hex` is the unsigned reveal to broadcast after the commit. On
    > create, `funding_tx_hex` must be the **signed attach commit** (the tx you
    > signed from `prep_psbt`), and `reveal_tx_hex` the value from the quote — do
    > not swap commit and reveal txids.

    The swap only becomes valid once the prep / fee tx **confirms on-chain**: the
    swap is persisted immediately but stays `confirmed: false` / `funded: false`
    until the watcher sees the confirmation.

    **Steps:**

    1. Client chooses what to list and an asking price. Either reference an
       existing `asset_utxo_id` (with `asset_name` + `asset_quantity` for
       `counterparty` / `zeld`), or omit `asset_utxo_id` and pass `asset_name` +
       `asset_quantity`. In the latter case the server first searches for a
       confirmed, non-locked UTXO already holding exactly that quantity; if one is
       found it is reused and no prep tx is composed (`prep_psbt: null`, separate
       fee PSBT unless fee is waived). Otherwise it composes the prep tx that
       creates the asset UTXO (Counterparty attach for `counterparty`, ZELD
       transfer for `zeld`).
    2. **`POST /api/atomic-swaps/sell-quotes`** — server composes and returns every
       unsigned PSBT the seller must sign: `prep_psbt` (+ `prep_inputs_to_sign`,
       `prep_kind`, optional `reveal_tx_hex`) when a prep tx is needed, `swap_psbt`
       (+ `swap_inputs_to_sign`), and `fee_psbt` (+ `fee_inputs_to_sign`) — plus
       the derived `asset_utxo_id` / `asset_utxo_value` and a `fee_payment_id`.
       When a prep tx is composed (a `zeld` transfer **or** a `counterparty`
       attach), the fee is folded into it: `fee_psbt` is `null` and
       `payment_address` / `payment_amount` describe the fee output the prep tx
       carries.
    3. Client signs each returned PSBT, **only** the indices listed in its
       `*_inputs_to_sign`. No re-composition; do not reorder inputs. For a `zeld`
       transfer prep, **finalize** the signed `prep_psbt` into a raw transaction
       (extract tx hex + txid) — that finalized tx is what you submit (as
       `zeld_payment` when the fee is paid on-chain, or as `funding_tx_hex` when the
       fee is waived), not the PSBT itself.
    4. **`POST /api/atomic-swaps`** — submit the signed `swap_psbt` as `psbt_hex`,
       the signed prep tx as `funding_tx_hex` (with `reveal_tx_hex` if the quote
       returned one), and the platform fee. For a `zeld` transfer prep the finalized
       transfer tx is normally carried by `zeld_payment` instead of `funding_tx_hex`
       — **except** when the fee is waived, where there is no `zeld_payment` and the
       finalized tx is sent as `funding_tx_hex`. The fee shape depends on how it was
       quoted:
       - `fee_payment` with a **signed fee PSBT** (`psbt_hex` + `fee_payment_id`) —
         for `counterparty` / `ordinal` / `zeld` listed from an **existing** asset
         UTXO.
       - `fee_payment` with **only** `fee_payment_id` (no PSBT) — for a
         `counterparty` **attach** prep tx: the fee is an extra output already
         inside the signed attach you send as `funding_tx_hex`, so there is nothing
         else to sign.
       - `zeld_payment` (`zeld_send_txid` + `zeld_send_tx_hex` + `fee_payment_id`)
         — for a `zeld` transfer prep paid **on-chain**; the hex is the
         **finalized** signed prep tx.
       - **no fee object**, finalized transfer tx in `funding_tx_hex` — for a `zeld`
         transfer prep when the fee is **waived** (active subscription / credits):
         `zeld_payment` is impossible without a `fee_payment_id`, so the finalized
         transfer tx is carried by `funding_tx_hex` instead.
    5. Server validates the PSBTs, broadcasts the prep tx (and reveal) when
       present, persists the swap, and watches for the prep / fee tx to confirm
       on-chain.

    > **Advanced (optional):** if the asset UTXO already exists you may skip the
    > prep tx entirely (pass `asset_utxo_id`); `prep_psbt` will be `null`. You may
    > also call `POST /api/atomic-swaps/fee-quotes` for the fee PSBT and build +
    > sign the single-input single-output swap PSBT yourself.

    ### 2. Fill a sell order (buy)

    1. **`GET /api/atomic-swaps`** — discover open swaps; filter by `asset_name` or
       `listing_type`.
    2. **`POST /api/atomic-swaps/buy-quotes`** — submit `swap_ids` and
       `buyer_address` (plus `buyer_taproot_address` for ordinals). The server
       selects asset-safe funding UTXOs, attaches the correct royalty output, and
       returns the unsigned buyer `psbt` with `inputs_to_sign`.
    3. Client signs the buyer inputs listed in `inputs_to_sign`. **Do not reorder
       inputs** — the optional detach OP_RETURN is keyed on the first input.
    4. **`POST /api/atomic-swaps/purchases`** — submit `swap_ids`, `buyer_address`,
       and the buyer-signed `psbt_hex`. Server merges the seller signatures,
       broadcasts the tx, and records pending sales.

    A single PSBT can fill several swaps **for the same asset** at once — just
    include every swap id in `swap_ids`.

    > **Advanced (optional):** instead of step 2 you may read
    > `GET /api/atomic-swaps/{id}` and build the buyer PSBT (funding UTXO, seller
    > payments, royalty output) yourself before signing.

    ### 3. Delist (cancel) a sell order

    1. **`POST /api/atomic-swaps/{id}/delist-requests`** — server returns a
       delist-request `id`.
    2. Client BIP322-signs that `id` string with the seller address.
    3. **`PUT /api/atomic-swaps/delist-requests/{request_id}`** — submit the
       signature (`request_id` path segment = `id` from step 1). Server verifies
       and marks the swap delisted.

    ### 4. Kontor listings (`listing_type: "kontor"`)

    **Kontor** listings cover both the native KOR token (18-decimal precision) and
    Kontor NFTs. The flow diverges from the PSBT-based flows: the Kontor SDK
    handles attachment and swap completely client-side; the server only stores and
    retrieves the resulting blob. `sell-quotes` and `buy-quotes` are **not used**
    for `kontor` listings; the swap fee is paid by the buyer via the SDK (funded by
    their taproot UTXOs), and there is no royalty mechanism for kontor assets.

    **Kontor sell:**

    1. **`POST /api/atomic-swaps/fee-quotes`** with
       `{ "type": "kontor", "address": "<seller>" }` to reserve the Horizon listing
       fee. The response returns `fee_payment_id`, `payment_address` and
       `payment_amount` (sats). Account holders paying with a subscription /
       credits may skip this step.
    2. Client builds a `KontorSession` and composes the offer, **splicing the
       listing fee into the attach reveal** as an extra output paying
       `payment_amount` to `payment_address`:
       - Token: `session.bind(Token, "token@0.0").attachment(Decimal.from(amount)).offer({ price, extraOutputs })`
       - NFT: `session.bind(Nft, contractAddress).attachment(nftId).offer({ price, extraOutputs })`

       The SDK broadcasts the attach commit + reveal immediately.
       `offer.serialize()` returns a JSON blob (`OfferData`).
    3. Client **persists the blob** before the POST (orphan-escrow protection: if
       the POST fails the seller can recover the escrowed asset by revoking the
       offer).
    4. **`POST /api/atomic-swaps`** with `listing_type: "kontor"` and
       kontor-specific fields:
       - `asset_utxo_id` = `${attachRevealTxid}:0` (output 0 of the attach reveal).
       - `kontor_offer_blob` = the raw JSON string from `offer.serialize()`.
       - `kontor_asset_kind` = `"token"` or `"nft"`.
       - `kontor_contract_address` = `"token@0.0"` or the NFT contract address.
       - `kontor_nft_id` = NFT id (null / omit for token listings).
       - `kontor_amount` = **positive decimal** amount string (null / omit for NFT
         listings).
       - `fee_payment` = `{ "fee_payment_id": "<id from step 1>" }`. The server
         verifies the attach reveal carries the exact reserved fee output before
         recording the listing. Omit only when paying with a subscription /
         credits.
       - `psbt_hex` is **not** required — set it to null or omit it.
    5. Server persists the swap; `confirmed` becomes `true` once the attach reveal
       is seen on the Kontor indexer (signet:
       `https://signet.kontor.network:35001/api`). As a fallback the same txid is
       checked via electrs if the indexer is unreachable.

    **Kontor buy:**

    1. **`GET /api/atomic-swaps/{id}`** — retrieve the listing; check
       `kontor_offer_blob`.
    2. Client calls `session.openOffer(kontor_offer_blob).inspect()` to validate
       statically (checks that the seller's detach PSBT signature is well-formed
       and that the price matches). Gate the "Buy" button on `inspection.valid`.
    3. Client calls `.accept()` — the SDK broadcasts the buyer commit tx and the
       swap reveal tx in one package. The buyer receives the asset. `.accept()`
       returns `{ txid }` (the swap reveal txid).
    4. **`POST /api/atomic-swaps/{id}/kontor-buy`** — record the purchase. Body:
       `{ "buyer_address": "...", "tx_id": "..." }`. The server verifies that
       `tx_id` spends the listing escrow (`asset_utxo_id`) on-chain before creating
       a pending-sale record.

    **Kontor delist / revoke:**

    1. Use the same BIP-322 delist flow as other listing types (steps 1–3 of
       *Delist a sell order* above) to mark the swap as delisted server-side.
    2. Client additionally calls `new Offer(session, JSON.parse(blob)).revoke()` to
       broadcast the detach transaction on-chain and reclaim the escrowed asset.
       This step requires a funded `KontorSession` (spendable taproot UTXOs).

    **Kontor addresses:** `seller_address` for `kontor` listings must be a **P2TR**
    (taproot) address. The Kontor SDK's `LocalAccount` derives the escrow and
    signing path from this address. P2WPKH buyer addresses are not required — the
    buyer's taproot address funds the swap commit.

servers:
  - url: https://horizon.market
    description: Mainnet
  - url: https://signet.horizon.market
    description: Signet

security:
  - {} # Most endpoints require no session — the on-chain signature is the auth.

tags:
  - name: Authentication
    description: |
      Optional endpoints. The atomic-swap endpoints do **not** require a session;
      these are only needed to attach a user account to activity. Verification of
      the OTP / signed challenge happens via the matching Auth.js credentials
      provider — `POST /api/auth/callback/OTP` or `POST /api/auth/callback/WALLET`
      (the wallet callback also requires the `wallet_provider` field) — each
      posted with a CSRF token, out of scope here.
  - name: Atomic Swaps
    description: The core marketplace resource.
  - name: Bitcoin
    description: |
      Read-only Bitcoin helpers (address UTXOs, raw transaction) plus a broadcast
      endpoint. Used by clients that compose or fund transactions themselves — for
      example the Kontor funding path fetches a seller/buyer's confirmed taproot
      UTXOs from `GET /api/bitcoin/address/{address}/utxos`. Responses are pinned to
      the host network (mainnet vs signet) and carry short browser-cache headers.
      Like the atomic-swap surface, they are CORS-open
      (`Access-Control-Allow-Origin: *`), so a cross-origin browser can call them
      directly.

paths:
  /api/auth/email/challenges:
    post:
      tags: [Authentication]
      operationId: requestEmailOtp
      summary: Request an email OTP
      description: Sends a 6-digit one-time code to the supplied email address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
            example:
              email: alice@example.com
      responses:
        "200":
          description: OTP sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
              example:
                success: true
        "400":
          description: Invalid email.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Invalid email

  /api/auth/wallet/challenges:
    post:
      tags: [Authentication]
      operationId: requestWalletChallenge
      summary: Request a BIP322 wallet challenge
      description: |
        Generates a nonce-bearing message that the client must sign with BIP322
        using the supplied Bitcoin address.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address:
                  type: string
            example:
              address: bc1q…
      responses:
        "200":
          description: Challenge generated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      nonce:
                        type: string
                      message:
                        type: string
              example:
                success: true
                data:
                  nonce: 9f1c…
                  message: "horizon.market wants you to sign in with your Bitcoin account: bc1q….Welcome to Horizon Market.Nonce: 9f1c….By signing this message you can prove the ownership of this address. Signing this message is safe and does not involve any funds or transactions."
        "400":
          description: Invalid address.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Invalid address

  /api/auth/wallet/token:
    post:
      tags: [Authentication]
      operationId: walletSignInToken
      summary: Verify a wallet challenge and return a bearer session token
      description: |
        CORS-open, token-returning wallet sign-in. Runs the same BIP322
        verification as the cookie-based `callback/WALLET` flow — creating the
        account and seeding 10 free credits on the first sign-in — but returns the
        Auth.js session **JWT** in the body instead of a `Set-Cookie`. Present the
        returned `token` as `Authorization: Bearer <token>` on `sell-quotes`,
        `POST /api/atomic-swaps`, and `GET /api/auth/credits`. This is how a
        cross-origin browser client authenticates, since it cannot read a
        cross-origin session cookie.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address, signature, nonce, wallet_provider]
              properties:
                address:
                  type: string
                  description: The Bitcoin address that signed the challenge.
                signature:
                  type: string
                  description: BIP322 (or BIP137) signature over the challenge `message`.
                nonce:
                  type: string
                  description: The `nonce` returned by `POST /api/auth/wallet/challenges`.
                wallet_provider:
                  type: string
                  description: Provider label recorded server-side.
                taproot_address:
                  type: string
                  description: Optional P2TR address linked to the account.
            example:
              address: bc1q…
              signature: Akg…
              nonce: 9f1c…
              wallet_provider: horizon-market-client
      responses:
        "200":
          description: Signed in — bearer token + current credit balance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      token:
                        type: string
                        description: "Auth.js session JWT — present as `Authorization: Bearer <token>`."
                      credits:
                        type: integer
                        description: Paid credits.
                      free_credits:
                        type: integer
                        description: Free monthly credits (0–10).
              example:
                success: true
                data:
                  token: eyJhbGciOi…
                  credits: 0
                  free_credits: 10
        "400":
          description: Malformed request.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Invalid address
        "401":
          description: Signature invalid, or challenge expired / already used.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: failed to verify signature

  /api/auth/credits:
    get:
      tags: [Authentication]
      operationId: getCredits
      summary: Read the authenticated account's credit balance
      description: |
        Returns the caller's remaining free + paid credits. Authenticates via
        `Authorization: Bearer <token>` (from `POST /api/auth/wallet/token`) or the
        Auth.js session cookie. Each listing opened without a subscription consumes
        one credit (free credits first, then paid).
      responses:
        "200":
          description: Current credit balance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    properties:
                      credits:
                        type: integer
                      free_credits:
                        type: integer
              example:
                success: true
                data:
                  credits: 0
                  free_credits: 9
        "401":
          description: Not authenticated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Unauthorized

  /api/atomic-swaps:
    get:
      tags: [Atomic Swaps]
      operationId: listAtomicSwaps
      summary: List atomic swaps
      description: |
        Returns a paginated list of atomic swaps matching the filters. Use
        `order_by` to sort by `created_at` (default), `updated_at`, `price`, or
        `price_per_unit`.

        **Kontor listings are excluded by default.** Because Kontor is
        signet-only, `kontor` rows are returned **only** when you explicitly pass
        `listing_type=kontor` (or `kontor_nft_id` to look up a specific NFT). A
        general query — no `listing_type` — never surfaces kontor listings on any
        network.

        **List vs detail shape:** items in `atomic_swaps[]` follow the `AtomicSwap`
        schema **minus `psbt_hex` and `on_chain_payment`**, which are omitted from
        the list to keep it light. Fetch `GET /api/atomic-swaps/{id}` to get those
        two fields (the single-swap detail returns the full `AtomicSwap`).

        **Prioritizing an address's pending orders:** pass `pending_address` to
        surface that address's in-progress orders **at the top** of the list. This
        *re-orders and includes* — it never removes rows a plain call returns. It
        brings forward, and includes even when the default filters would hide them:
        the address's **pending sell orders** (its own listings still settling
        on-chain — `confirmed` and/or `funded` still `false`) and its **pending
        purchases** (swaps it is buying whose buy tx is broadcast but not yet
        confirmed). Each prioritized item carries `pending_role: "seller" | "buyer"`
        plus `pending_txid` — the still-unconfirmed tx to link to (e.g.
        `mempool.space`); every other item has both set to `null`.
      parameters:
        - name: asset_name
          in: query
          required: false
          schema: { type: string }
          description: Exact match on the listed asset.
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: Case-insensitive substring match on `asset_name`.
        - name: seller_address
          in: query
          required: false
          schema: { type: string }
          description: Filter by seller Bitcoin address.
        - name: buyer_address
          in: query
          required: false
          schema: { type: string }
          description: Filter by buyer Bitcoin address (set once a swap is filled).
        - name: listing_type
          in: query
          required: false
          schema: { $ref: '#/components/schemas/ListingType' }
          description: One of `counterparty`, `ordinal`, `zeld`, `kontor`.
        - name: kontor_nft_id
          in: query
          required: false
          schema: { type: string }
          description: |
            Look up a specific Kontor NFT listing by its `kontor_nft_id`. Like
            `listing_type=kontor`, this surfaces `kontor` rows (which have no
            `asset_name`) that the default query otherwise hides.
        - name: pending_address
          in: query
          required: false
          schema: { type: string }
          description: |
            Prioritizes (does **not** filter) this address's in-progress orders,
            sorting them to the top and including them even when the default
            `delisted`/`funded` filters would hide them:
            **pending sell orders** (`seller_address` = this address on a listing
            still settling on-chain — `confirmed` and/or `funded` still `false`) and
            **pending purchases** (this address is the buyer of an in-flight buy tx
            recorded in the swap's pending sales, before it confirms). Each such
            item is returned with `pending_role` set to `seller` or `buyer`
            accordingly, and `pending_txid` set to the still-unconfirmed tx to link
            to (e.g. on `mempool.space`); all other items keep both `null`. The
            `order` /
            `order_by` sort still applies within the prioritized block and the rest
            of the list. The prioritized block is not narrowed by the other filters
            (`asset_name`, `listing_type`, etc.) — `pending_address` is intended as
            a standalone "my orders" surface.
        - name: funded
          in: query
          required: false
          schema: { type: boolean }
          description: When `true`, only swaps whose fee tx confirmed. Defaults to the opposite of `sales`.
        - name: filled
          in: query
          required: false
          schema: { type: boolean }
          description: |
            **No effect on this endpoint.** Accepted for backward compatibility
            but ignored by the list query — pass `sales=true` to return completed
            sales. (`filled` *is* honoured on `GET /api/atomic-swaps/asset-name`.)
        - name: delisted
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: Include delisted swaps. Default `false`.
        - name: unattached
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: |
            When `true`, include swaps without an attached `asset_name`
            (placeholder / in-progress listings). Default `false`; when omitted or
            `false`, only swaps with a non-null `asset_name` are returned —
            **except** when `listing_type=kontor`, where KOR-token and NFT
            listings (which have no `asset_name`) are always returned.
        - name: sales
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: When `true`, returns filled swaps (history). Default `false`.
        - name: order
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: desc }
          description: '`asc` or `desc`. Default `desc`.'
        - name: order_by
          in: query
          required: false
          schema:
            type: string
            enum: [created_at, updated_at, price, price_per_unit]
            default: created_at
          description: One of `created_at` (default), `updated_at`, `price`, `price_per_unit`. Any other value returns `400`.
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0, default: 0 }
          description: |
            Zero-based pagination offset. Default `0`. Must be **non-negative** — a
            negative value returns `400 "offset must be a non-negative integer"`.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1 }
          description: |
            Page size. Defaults to no limit (no server-side cap is currently
            applied). When supplied it must be a **positive** integer — `0` or a
            negative value returns `400 "limit must be a positive integer"`.
      responses:
        "200":
          description: Paginated list of atomic swaps.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [count, atomic_swaps, pagination]
                    properties:
                      count:
                        type: integer
                        description: Total matching rows (same value as `pagination.total`).
                      atomic_swaps:
                        type: array
                        items: { $ref: '#/components/schemas/AtomicSwap' }
                      pagination: { $ref: '#/components/schemas/Pagination' }
        "400":
          description: Invalid query parameter.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: limit must be a positive integer
    post:
      tags: [Atomic Swaps]
      operationId: createAtomicSwap
      summary: Create a sell order
      security:
        - {}
        - sessionCookie: []
      description: |
        Lists a new atomic swap. The server validates the seller's signed swap
        PSBT and the platform-fee payment, broadcasts the preparation tx when one
        is supplied, then publishes the listing. When a prep tx is involved the
        swap is created with `confirmed: false` / `funded: false` and only becomes
        purchasable once that tx confirms on-chain.

        `psbt_hex` is the signed `swap_psbt`. `asset_utxo_id` / `asset_utxo_value`
        are the values returned by `sell-quotes` — pass them back verbatim. Pay
        the platform fee with **one** of:

        * `fee_payment` (signed PSBT) — for `counterparty` / `ordinal`, and for
          `zeld` when listing from an **existing** asset UTXO: the signed
          `fee_psbt` and the `fee_payment_id`.
        * `fee_payment` (counterparty attach) — for a `counterparty` **attach**
          prep tx: the fee is an extra output **inside the attach** that the
          server broadcasts as `funding_tx_hex`, so `fee_payment` carries only the
          `fee_payment_id` — no fee PSBT.
        * `zeld_payment` — for `zeld` **transfer prep** listings paid on-chain:
          finalize the signed `prep_psbt` into a raw tx, then submit its
          `zeld_send_txid` + `zeld_send_tx_hex` plus the `fee_payment_id`.
        * **no fee object** (waived `zeld` transfer prep) — when the fee is waived,
          a `zeld` transfer prep cannot use `zeld_payment` (it requires a
          `fee_payment_id`): send the **finalized** transfer tx via `funding_tx_hex`
          and omit both `fee_payment` and `zeld_payment`.
        * `fee_payment` (kontor) — for `kontor` listings: the fee rides inside the
          attach reveal broadcast by the Kontor SDK, so `fee_payment` carries only
          the `fee_payment_id` — no fee PSBT and no swap `psbt_hex`.

        The server verifies the asset UTXO really holds the listed asset.
        Mismatches return `400`. For `listing_type: "zeld"`, `asset_name` must be
        `"ZELD"`.

        **Account holders (optional):** a client with an authenticated session
        carrying an active **subscription** or **credits** may omit the fee object
        entirely — the platform fee is waived. A session holder with subscription
        or credits that supplies a fee object is rejected with `400`. Anonymous
        callers must always pay on-chain.

        When `sell-quotes` returned a prep tx, also include the **signed** prep tx
        via `funding_tx_hex` (and `reveal_tx_hex` when the attach quote returned
        one) so the server can broadcast it. For a `zeld` transfer prep paid
        on-chain the signed transfer tx is carried by `zeld_payment.zeld_send_tx_hex`
        instead, so `funding_tx_hex` is not needed; when that same `zeld` transfer is
        fee-waived, send the finalized transfer tx via `funding_tx_hex` (there is no
        `zeld_payment`).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateAtomicSwapRequest' }
            examples:
              counterpartyExistingUtxo:
                summary: counterparty with existing UTXO
                value:
                  asset_utxo_id: "abc123…:0"
                  asset_utxo_value: 546
                  asset_name: RAREPEPE
                  asset_quantity: 1
                  price: 250000
                  seller_address: bc1q…
                  psbt_hex: 70736274ff01…
                  listing_type: counterparty
                  expires_at: "2026-06-01T00:00:00Z"
                  fee_payment:
                    psbt_hex: 70736274ff01…
                    fee_payment_id: 9f1c0b…
              counterpartyAttachPrep:
                summary: counterparty with attach prep tx (fee folded into attach)
                value:
                  asset_utxo_id: "def456…:0"
                  asset_utxo_value: 546
                  asset_name: RAREPEPE
                  asset_quantity: 1
                  price: 250000
                  seller_address: bc1q…
                  psbt_hex: 70736274ff01…
                  listing_type: counterparty
                  funding_tx_hex: 0200000001…
                  fee_payment:
                    fee_payment_id: 9f1c0b…
              zeldTransferPrep:
                summary: zeld with transfer prep tx
                value:
                  asset_utxo_id: "789abc…:0"
                  asset_utxo_value: 330
                  asset_name: ZELD
                  asset_quantity: 100000000
                  price: 250000
                  seller_address: bc1q…
                  psbt_hex: 70736274ff01…
                  listing_type: zeld
                  zeld_payment:
                    zeld_send_txid: 789abc…
                    zeld_send_tx_hex: 0200000001…
                    fee_payment_id: 9f1c0b…
              zeldTransferPrepWaived:
                summary: zeld transfer prep, fee waived (subscription / credits)
                value:
                  asset_utxo_id: "789abc…:0"
                  asset_utxo_value: 330
                  asset_name: ZELD
                  asset_quantity: 100000000
                  price: 250000
                  seller_address: bc1q…
                  psbt_hex: 70736274ff01…
                  listing_type: zeld
                  funding_tx_hex: 0200000001…
              zeldExistingUtxo:
                summary: zeld with existing asset UTXO
                value:
                  asset_utxo_id: "fedcba…:0"
                  asset_utxo_value: 330
                  asset_name: ZELD
                  asset_quantity: 100000000
                  price: 250000
                  seller_address: bc1q…
                  psbt_hex: 70736274ff01…
                  listing_type: zeld
                  fee_payment:
                    psbt_hex: 70736274ff01…
                    fee_payment_id: 9f1c0b…
              kontorToken:
                summary: kontor KOR token
                value:
                  asset_utxo_id: "aabbcc…:0"
                  asset_utxo_value: 0
                  price: 50000
                  seller_address: bc1p…
                  listing_type: kontor
                  kontor_offer_blob: '{"v":1,"attachReveal":"0200…"}'
                  kontor_asset_kind: token
                  kontor_contract_address: token@0.0
                  kontor_amount: "100.5"
                  fee_payment:
                    fee_payment_id: 9f1c0b…
              kontorNft:
                summary: kontor NFT
                value:
                  asset_utxo_id: "ddeeff…:0"
                  asset_utxo_value: 0
                  price: 100000
                  seller_address: bc1p…
                  listing_type: kontor
                  kontor_offer_blob: '{"v":1,"attachReveal":"0200…"}'
                  kontor_asset_kind: nft
                  kontor_contract_address: nft@1234.0
                  kontor_nft_id: my-nft-id
                  fee_payment:
                    fee_payment_id: 9f1c0b…
      responses:
        "201":
          description: Listing created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AtomicSwapCreated' }
        "200":
          description: |
            Idempotent replay — an open listing already exists for the same seller
            asset UTXO with identical parameters, so the existing swap is returned
            instead of creating a duplicate. Applies to all non-`kontor` listing
            types; `kontor` listings are not idempotency-guarded.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AtomicSwapCreated' }
        "400":
          description: Bad request.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: "Bad request: invalid fee PSBT"
        "401":
          description: |
            Returned only on the **fee-waived** path, and **only** when a session is
            present but its account has neither an active subscription nor remaining
            credits while the request omits the fee object. A request that carries
            **no session** and omits the fee is rejected earlier with **`400`**
            (`...required for anonymous listings`), not `401`. Anonymous callers must
            pay the fee on-chain instead (see `fee_payment` / `zeld_payment`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Unauthorized
        "404":
          description: |
            `listing_type: "kontor"` attempted on a non-signet network. Kontor is
            signet-only, so creating a kontor listing on mainnet is rejected with a
            JSON `{ "error": "Not found" }` body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Not found
        "409":
          description: |
            Conflicting listing already open on the same seller UTXO (the error
            names the `listing_type`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Conflicting counterparty listing
        "500":
          description: |
            Broadcast of the prep / reveal / fee transaction failed, or an
            unexpected server error occurred while persisting the listing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/atomic-swaps/asset-utxo-id:
    get:
      tags: [Atomic Swaps]
      operationId: listLockedAssetUtxoIds
      summary: List locked asset UTXO ids
      description: |
        Returns the set of asset UTXO ids (`txid:vout`) currently locked in active
        atomic swap listings for the given seller address(es). A UTXO is locked
        when its swap is filled, not yet delisted, or still awaiting on-chain fee
        confirmation. When both `seller_address` and `seller_addresses` are
        supplied, the results are merged and deduplicated.

        Use this when selecting funding / fee inputs so listed asset UTXOs are
        never accidentally spent. The `data` payload maps each locked
        `asset_utxo_id` to `true`; an empty object means nothing is locked.
      parameters:
        - name: seller_address
          in: query
          required: false
          schema: { type: string }
          description: Return locked UTXOs for a single seller address.
        - name: seller_addresses
          in: query
          required: false
          schema: { type: string }
          description: Comma-separated seller addresses.
      responses:
        "200":
          description: Map of locked asset UTXO ids.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    additionalProperties:
                      type: boolean
              example:
                data:
                  "abc123def4567890:0": true
                  "fedcba0987654321:1": true

  /api/atomic-swaps/asset-name:
    get:
      tags: [Atomic Swaps]
      operationId: searchAssetNames
      summary: Search listed asset names
      description: |
        Returns distinct asset names matching the query, plus media descriptors
        for Counterparty assets. Useful for building marketplace search UIs.
      parameters:
        - name: query
          in: query
          required: false
          schema: { type: string }
          description: Case-insensitive substring match on listed asset names. Default empty (returns all).
        - name: filled
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: When `true`, search filled swaps only. Default `false`.
        - name: limit
          in: query
          required: false
          schema: { type: integer }
          description: Maximum number of asset names to return.
      responses:
        "200":
          description: Matching asset names with media descriptors.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [asset_names, asset_media]
                    properties:
                      asset_names:
                        type: array
                        items: { type: string }
                      asset_media:
                        type: object
                        description: Map of `asset_name` → media descriptor.
                        additionalProperties: true
              example:
                data:
                  asset_names: [RAREPEPE, MEME]
                  asset_media:
                    RAREPEPE:
                      image_url: https://…

  /api/atomic-swaps/asset-image:
    get:
      tags: [Atomic Swaps]
      operationId: getAssetImage
      summary: Resolve the image of any asset
      description: |
        Resolves the displayable image for any asset, independent of whether a
        swap exists for it, so a client can render an asset's artwork directly:

        * `XCP` returns the Counterparty logo, `ZELD` the ZeldHash logo, and
          `KOR` the Kontor mark.
        * Counterparty assets, ordinal inscriptions, and Kontor NFTs return
          their real per-asset image.

        `asset` is the identifier for the given `listing_type`: a Counterparty
        asset name (`counterparty`), an inscription id `<txid>iN` (`ordinal`),
        or a Kontor nft id (`kontor`). Image URLs are absolute.

        Pass `redirect=image` or `redirect=thumbnail` to 302-redirect straight
        to the resolved URL instead of returning JSON — use that form as an
        `<img>` source, e.g.
        `<img src="https://horizon.market/api/atomic-swaps/asset-image?asset=XCP&redirect=image">`.
        It responds `404` when the asset has no image.
      parameters:
        - name: asset
          in: query
          required: true
          schema: { type: string }
          description: |
            Asset identifier. Counterparty asset name (e.g. `XCP`, `RAREPEPE`),
            ordinal inscription id (`<txid>iN`), or Kontor nft id. `KOR` for the
            fungible Kontor token.
        - name: listing_type
          in: query
          required: false
          schema:
            type: string
            enum: [counterparty, ordinal, zeld, kontor]
            default: counterparty
          description: |
            Asset family. Defaults to `counterparty`, which also covers `XCP`.
            `ZELD` is recognised by name even without this parameter.
        - name: redirect
          in: query
          required: false
          schema:
            type: string
            enum: [image, thumbnail]
          description: |
            When set, 302-redirect to the full-size (`image`) or `thumbnail`
            URL instead of returning JSON. Suitable as an `<img src>`.
      responses:
        "200":
          description: Resolved media descriptor (when `redirect` is omitted).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required:
                      [asset, listing_type, image_url, thumbnail_url, inscription_number]
                    properties:
                      asset: { type: string }
                      listing_type:
                        type: string
                        enum: [counterparty, ordinal, zeld, kontor]
                        description: >-
                          The type the asset was resolved as. May differ from
                          the request when an asset is recognised by name (e.g.
                          `ZELD` always resolves as `zeld`).
                      image_url:
                        type: string
                        nullable: true
                        description: Full-size image URL, or `null` when none exists.
                      thumbnail_url:
                        type: string
                        nullable: true
                        description: Thumbnail URL (equals `image_url` for most types).
                      inscription_number:
                        type: integer
                        nullable: true
                        description: Ordinal inscription number; `null` for non-ordinals.
              example:
                data:
                  asset: XCP
                  listing_type: counterparty
                  image_url: https://horizon.market/xcp-logo.png
                  thumbnail_url: https://horizon.market/xcp-logo.png
                  inscription_number: null
        "302":
          description: |
            Redirect to the resolved image URL (when `redirect` is set).
          headers:
            Location:
              schema: { type: string }
              description: The resolved image or thumbnail URL.
        "400":
          description: Missing `asset` or invalid parameter.
        "404":
          description: The asset has no image (only when `redirect` is set).

  /api/atomic-swaps/{id}/pending-sales/{address}:
    get:
      tags: [Atomic Swaps]
      operationId: listPendingSaleTxIds
      summary: List pending purchase tx ids
      description: |
        Returns broadcast transaction ids for in-flight purchases of this swap by
        the given buyer address. Poll after `POST /api/atomic-swaps/purchases`
        until the swap shows `filled: true` or a confirmation watcher clears the
        pending state.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Atomic swap id.
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: Buyer Bitcoin address.
      responses:
        "200":
          description: Broadcast transaction ids.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { type: string }
              example:
                data: [e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855]

  /api/atomic-swaps/{id}:
    get:
      tags: [Atomic Swaps]
      operationId: getAtomicSwap
      summary: Get one atomic swap
      description: |
        Returns the full state of a swap, including the seller PSBT and on-chain
        payment status. Use this immediately before building a buyer PSBT so you
        have the freshest royalty / price information.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Atomic swap id.
      responses:
        "200":
          description: Atomic swap detail.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/AtomicSwap' }
        "404":
          description: Atomic swap not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Atomic swap not found

  /api/atomic-swaps/purchases:
    post:
      tags: [Atomic Swaps]
      operationId: createPurchase
      summary: Buy one or several swaps
      description: |
        Fills atomic swaps with a single buyer-signed PSBT. All swaps referenced
        in `swap_ids` must list the **same asset** so they can share the buyer
        payment outputs.

        The buyer PSBT must spend a funding UTXO the buyer controls, pay each
        seller the swap `price` to its `seller_address`, include any royalty
        outputs the asset requires, and be signed by the buyer on every buyer
        input. The server merges the seller signatures it already holds, finalizes
        the remaining inputs, and broadcasts the resulting transaction.

        Unlike the quote/create endpoints, this route accepts **only** a flat JSON
        body (no `{ "data": … }` wrapper).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [swap_ids, buyer_address, psbt_hex]
              properties:
                swap_ids:
                  type: array
                  items: { type: string }
                buyer_address:
                  type: string
                  description: Must be a **P2WPKH** (`bc1q…` / `tb1q…`) address; other address types are rejected with `400`.
                psbt_hex:
                  type: string
            example:
              swap_ids: [swap_abc, swap_def]
              buyer_address: bc1q…
              psbt_hex: 70736274ff01…
      responses:
        "200":
          description: Pending sales recorded.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PendingSale' }
        "400":
          description: |
            Invalid request — a non-P2WPKH `buyer_address`, or a referenced swap
            that is not yet confirmed, already filled, or otherwise not purchasable
            (e.g. flagged anomalous).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: "All swaps must be for the same asset. Found mixed assets: RAREPEPE and PEPECASH"
        "404":
          description: Swap not found, delisted (seller-only purchase), or asset no longer available.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: "Atomic swap not found: swap_def"

  /api/atomic-swaps/fee-quotes:
    post:
      tags: [Atomic Swaps]
      operationId: createFeeQuote
      summary: Request a fee quote
      description: |
        Returns the platform fee payment scaffolding needed before creating a swap.

        For `counterparty` / `ordinal` listings — and for `zeld` when the ZELD
        amount is already isolated on its own UTXO — supply `address`,
        `utxo_set_ids` to pay the fee from, and a `sats_per_vbyte` feerate target.
        The response contains the unsigned **BTC** fee PSBT.

        For the **advanced** `zeld` transfer-prep path only, supply
        `{ "type": "zeld", "address": "<seller>" }`. The response contains
        `payment_address` and `payment_amount`: the platform-fee destination and
        **BTC** amount in sats to include as an output inside your transfer tx.

        For `kontor` listings, supply `{ "type": "kontor", "address": "<seller>" }`.
        Same `fee_payment_id` / `payment_address` / `payment_amount` shape as the
        `zeld` variant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FeeQuoteRequest' }
            examples:
              counterpartyOrOrdinal:
                summary: counterparty or ordinal
                value:
                  address: bc1q…
                  utxo_set_ids: ["abc:0", "def:1"]
                  sats_per_vbyte: 5
              zeld:
                summary: zeld (advanced transfer-prep path)
                value:
                  type: zeld
                  address: bc1q…
              kontor:
                summary: kontor
                value:
                  type: kontor
                  address: bc1p…
      responses:
        "200":
          description: |
            Fee quote. The PSBT variant (`psbt` / `raw_transaction` /
            `inputs_to_sign`) is returned for `counterparty` / `ordinal` / existing
            `zeld` UTXO; the output variant (`payment_address` / `payment_amount`)
            is returned for the `zeld` / `kontor` `type`-tagged forms.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/FeeQuoteResult' }
              examples:
                psbtVariant:
                  summary: counterparty / ordinal
                  value:
                    data:
                      fee_payment_id: 9f1c0b…
                      psbt: 70736274ff01…
                      raw_transaction: 0200000001…
                      inputs_to_sign: [0, 1]
                outputVariant:
                  summary: zeld / kontor
                  value:
                    data:
                      fee_payment_id: 9f1c0b…
                      payment_address: bc1q…
                      payment_amount: 32000
        "400":
          description: |
            `address is required` for the `zeld` / `kontor` (`type`-tagged)
            variants. The `counterparty` / `ordinal` variant returns
            `Invalid address` when `address` is present but not a valid Bitcoin
            address, and also returns `400` when `utxo_set_ids` or `sats_per_vbyte`
            is omitted (both are required there and have no server-side default).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: address is required

  /api/atomic-swaps/sell-quotes:
    post:
      tags: [Atomic Swaps]
      operationId: createSellQuote
      summary: Compose a sell order quote
      security:
        - {}
        - sessionCookie: []
      description: |
        Composes **every** unsigned tx needed to open a listing, server-side, so
        the client only signs. Returns the asset **swap PSBT** plus — when required
        — a **prep PSBT** (`prep_psbt`) that creates the asset UTXO and the listing
        **fee PSBT** (`fee_psbt`), together with the derived `asset_utxo_id` /
        `asset_utxo_value` and a `fee_payment_id`.

        Every response also carries an upfront **cost breakdown**:
        `listing_fee_sats` (platform fee; `null` when waived), `attach_fee_sats`
        (miner fee of the prep tx; `null` when an existing UTXO is reused), and
        `network_fee_sats` (miner fee of the standalone platform-fee tx; `null`
        when the fee is folded into the prep tx or waived).

        Pass `preview: true` to compute the cost breakdown **without** persisting
        an `OnChainPayment` record. PSBTs returned by a preview quote must **not**
        be signed or submitted.

        When the caller has an active **subscription** or **credits**, the fee is
        waived: `fee_psbt` / `fee_payment_id` are `null` and `fee_waived` is
        `true`.

        Identify the asset either by passing an **existing** `asset_utxo_id`
        (`prep_psbt` is `null`; required for `ordinal`), or by **omitting** it and
        passing `asset_quantity` (+ `asset_name` for `counterparty`) so the server
        composes the prep tx and returns it as `prep_psbt` with `prep_kind`
        (`zeld_transfer` | `attach`). Use the returned `asset_utxo_id` as-is.

        **Counterparty (XCP) decision matrix** (when `asset_utxo_id` is omitted):

        | Existing UTXO with exact amount? | Credits / subscription? | Result |
        | --- | --- | --- |
        | Yes | Yes | `prep_psbt: null`, `fee_psbt: null`, `fee_waived: true` |
        | Yes | No | `prep_psbt: null`, `fee_psbt` returned, `fee_waived: false` |
        | No (sufficient address balance) | Yes | `prep_psbt` = attach tx, `fee_psbt: null`, `fee_waived: true` |
        | No (sufficient address balance) | No | `prep_psbt` = attach tx, `fee_psbt: null`, fee folded into the attach, `payment_address` / `payment_amount` set, `fee_waived: false` |

        When the address holds less than `asset_quantity` of the asset
        (address-level balance, not UTXO-attached), the server returns `400` with a
        clear message before composing anything.

        **ZELD decision matrix** (when `asset_utxo_id` is omitted):

        | Existing UTXO with exact amount? | Credits / subscription? | Result |
        | --- | --- | --- |
        | Yes | Yes | `prep_psbt: null`, `fee_psbt: null`, `fee_waived: true` |
        | Yes | No | `prep_psbt: null`, `fee_psbt` returned, `fee_waived: false` |
        | No | Yes | `prep_psbt` = transfer tx (no fee output), `fee_psbt: null`, `fee_waived: true` |
        | No | No | `prep_psbt` = transfer+fee tx, `fee_psbt: null`, `payment_address` / `payment_amount` set |

        **Ordinals:** `asset_utxo_id` is always required (no auto-detection).
        `prep_psbt` is always `null`. The server verifies the UTXO holds an
        inscription and belongs to `seller_address`. `asset_quantity` must be `1`
        or omitted. The fee follows the same credit logic as other types.

        `seller_pubkey` is required when `seller_address` is a P2TR address.
        `sats_per_vbyte` defaults to the current half-hour feerate. `expires_at` is
        **not** part of the quote; set it on create.

        Fee / funding UTXOs are chosen automatically; pass `fee_utxo_ids` to use
        specific ones. `auto_select_fee_utxos` and `fee_utxo_ids` are mutually
        exclusive: the conflict is rejected with `400` only when
        `auto_select_fee_utxos` is `true` **and** `fee_utxo_ids` is non-empty. A
        truthy `auto_select_fee_utxos` otherwise takes precedence and any
        `fee_utxo_ids` is ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SellQuoteRequest' }
            examples:
              counterpartyExistingUtxo:
                summary: counterparty existing asset UTXO
                value:
                  asset_utxo_id: "abc123…:0"
                  asset_name: RAREPEPE
                  asset_quantity: 1
                  price: 250000
                  seller_address: bc1q…
                  seller_pubkey: 02ab…
                  listing_type: counterparty
                  sats_per_vbyte: 5
                  auto_select_fee_utxos: true
              counterpartyComposeAttach:
                summary: counterparty compose attach prep tx
                value:
                  asset_name: RAREPEPE
                  asset_quantity: 1
                  price: 250000
                  seller_address: bc1q…
                  listing_type: counterparty
                  sats_per_vbyte: 5
              ordinalExistingInscription:
                summary: ordinal existing inscription UTXO
                value:
                  asset_utxo_id: "111222…:0"
                  price: 500000
                  seller_address: bc1q…
                  listing_type: ordinal
                  sats_per_vbyte: 5
                  auto_select_fee_utxos: true
              zeldExistingUtxo:
                summary: zeld existing asset UTXO
                value:
                  asset_utxo_id: "fedcba…:0"
                  asset_name: ZELD
                  asset_quantity: 100000000
                  price: 250000
                  seller_address: bc1q…
                  listing_type: zeld
                  auto_select_fee_utxos: true
              zeldComposeTransfer:
                summary: zeld compose transfer prep tx
                value:
                  asset_name: ZELD
                  asset_quantity: 100000000
                  price: 250000
                  seller_address: bc1q…
                  listing_type: zeld
      responses:
        "200":
          description: Composed sell-order quote.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/SellQuoteResult' }
              examples:
                counterpartyExistingUtxo:
                  summary: counterparty existing UTXO (separate fee PSBT)
                  value:
                    data:
                      swap_psbt: 70736274ff01…
                      swap_inputs_to_sign: [1]
                      fee_psbt: 70736274ff01…
                      fee_inputs_to_sign: [0, 1]
                      fee_payment_id: 9f1c0b…
                      fee_waived: false
                      asset_utxo_id: "abc123…:0"
                      asset_utxo_value: 546
                      prep_psbt: null
                      prep_inputs_to_sign: []
                      prep_kind: null
                counterpartyAttach:
                  summary: counterparty attach (fee folded, reveal present)
                  value:
                    data:
                      swap_psbt: 70736274ff01…
                      swap_inputs_to_sign: [1]
                      fee_psbt: null
                      fee_inputs_to_sign: []
                      fee_payment_id: 9f1c0b…
                      fee_waived: false
                      asset_utxo_id: "reveal789…:0"
                      asset_utxo_value: 546
                      prep_psbt: 70736274ff01…
                      prep_inputs_to_sign: [0, 1]
                      prep_kind: attach
                      reveal_tx_hex: 0200000001…
                      payment_address: bc1q…
                      payment_amount: 32000
                zeldTransfer:
                  summary: zeld transfer prep (fee folded)
                  value:
                    data:
                      swap_psbt: 70736274ff01…
                      swap_inputs_to_sign: [1]
                      fee_psbt: null
                      fee_inputs_to_sign: []
                      fee_payment_id: 9f1c0b…
                      fee_waived: false
                      asset_utxo_id: "789abc…:0"
                      asset_utxo_value: 330
                      prep_psbt: 70736274ff01…
                      prep_inputs_to_sign: [0]
                      prep_kind: zeld_transfer
                      payment_address: bc1q…
                      payment_amount: 32000
        "400":
          description: |
            A non-P2WPKH/P2TR (legacy / P2SH) `seller_address`, missing
            `seller_pubkey` for a P2TR seller, conflicting `fee_utxo_ids` /
            `auto_select_fee_utxos`, insufficient asset / BTC / ZELD balance, missing
            `asset_utxo_id` for ordinals, an ordinal UTXO that holds no
            inscription / is already spent / is not owned by the seller / has
            `asset_quantity != 1`, or a `counterparty` **attach** whose folded
            platform fee is below the 546-sat dust limit (the fee cannot be folded
            into the attach output).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missingSellerPubkey:
                  value:
                    error: seller_pubkey is required for P2TR seller addresses.
                nonSegwitSeller:
                  value:
                    error: seller_address must be a P2WPKH or P2TR address.
                insufficientAssetBalance:
                  value:
                    error: "Not enough RAREPEPE available to list directly (50 unattached, 100 required). The rest is locked in existing UTXOs — detach it first with the Detach tool (/tools/detach), then try again."
                insufficientBtcAttach:
                  value:
                    error: "Insufficient BTC to compose the attach transaction (asset move + listing fee). Send some BTC to your address first."
                insufficientBtcFee:
                  value:
                    error: "Insufficient BTC to pay the platform fee: need 32000 sats but only 15000 sats available in confirmed UTXOs."
                insufficientZeld:
                  value:
                    error: "Insufficient ZELD balance: address holds 50000000 but 100000000 is required."
                insufficientBtcZeldTransfer:
                  value:
                    error: Insufficient BTC to compose the ZELD transfer transaction. Send some BTC to your address first.
                ordinalUtxoRequired:
                  value:
                    error: asset_utxo_id is required for ordinal listings.
                ordinalNoInscription:
                  value:
                    error: "Asset UTXO 111222…:0 holds no inscription. Make sure the UTXO contains a valid Ordinal inscription."
                ordinalSpent:
                  value:
                    error: "Asset UTXO 111222…:0 has already been spent and cannot be listed."
                ordinalNotOwned:
                  value:
                    error: "Asset UTXO 111222…:0 is not owned by bc1q… — the inscription belongs to bc1p…."
                ordinalQuantity:
                  value:
                    error: "asset_quantity must be 1 (or omitted) for ordinal listings — each inscription is unique."
                noBtcForFee:
                  value:
                    error: "Insufficient BTC: no confirmed asset-free UTXOs available to pay the platform fee. Send some BTC to your address first."
                dustFoldedFee:
                  value:
                    error: "Listing fee (300 sats) is below the dust limit and cannot be folded into the attach transaction."

  /api/atomic-swaps/buy-quotes:
    post:
      tags: [Atomic Swaps]
      operationId: createBuyQuote
      summary: Compose a buy quote
      description: |
        Composes the unsigned buyer PSBT to fill one or more swaps, server-side,
        so the client only signs. The server selects asset-safe funding UTXOs,
        attaches the royalty output, and optionally detaches the bought assets to
        the buyer address.

        All `swap_ids` must reference the **same asset**. Ordinal purchases accept
        a single swap and require `buyer_taproot_address`. Funding UTXOs are chosen
        automatically; pass `funding_utxo_ids` to spend specific ones. `auto_select`
        and `funding_utxo_ids` are mutually exclusive (supplying both returns
        `400`). `detach` defaults to `true` (ignored for `zeld`). `sats_per_vbyte`
        defaults to the current half-hour feerate. Royalty outputs below the
        546-sat dust limit are omitted (`royalty_sats: 0`). For `ordinal` listings
        the composed buyer PSBT has no royalty output.

        Sign every input listed in `inputs_to_sign` and **do not reorder the
        inputs** before signing, then submit to `POST /api/atomic-swaps/purchases`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BuyQuoteRequest' }
            examples:
              counterpartyOrZeld:
                summary: counterparty or zeld
                value:
                  swap_ids: [swap_abc, swap_def]
                  buyer_address: bc1q…
                  sats_per_vbyte: 5
                  detach: true
                  auto_select: true
              ordinal:
                summary: ordinal
                value:
                  swap_ids: [swap_abc]
                  buyer_address: bc1q…
                  buyer_taproot_address: bc1p…
                  sats_per_vbyte: 5
              explicitFundingUtxos:
                summary: explicit funding UTXOs
                value:
                  swap_ids: [swap_abc]
                  buyer_address: bc1q…
                  funding_utxo_ids: ["abc123…:0", "def456…:1"]
      responses:
        "200":
          description: Composed buyer PSBT.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/BuyQuoteResult' }
              example:
                data:
                  psbt: 70736274ff01…
                  inputs_to_sign: [0, 3]
                  fee_estimate_sats: 1240
                  royalty_sats: 13157
                  royalty_address: bc1q…
        "400":
          description: |
            Invalid parameters (non-P2WPKH `buyer_address`, missing taproot address
            for ordinals, multiple ordinal swaps, conflicting `funding_utxo_ids` /
            `auto_select`), swap not yet purchasable, or no spendable funding UTXOs
            available for the buyer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                ordinalTaprootRequired:
                  value:
                    error: buyer_taproot_address is required for ordinal purchases.
                ordinalSingleSwap:
                  value:
                    error: Ordinal purchases support a single swap per quote.
                nonP2wpkhBuyer:
                  value:
                    error: "buyer_address must be P2WPKH (native SegWit: bc1q… on mainnet, tb1q… on testnet)."
        "404":
          description: Swap not found, delisted (seller-only purchase), or asset no longer available.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: "Atomic swap not found: swap_def"

  /api/atomic-swaps/{id}/kontor-buy:
    post:
      tags: [Atomic Swaps]
      operationId: recordKontorBuy
      summary: Record a Kontor swap
      description: |
        Records a completed Kontor swap purchase. The buyer has already broadcast
        the swap reveal transaction client-side via `incomingOffer.accept()`; this
        endpoint persists the result after verifying that `tx_id` spends the
        listing escrow (`asset_utxo_id`) on-chain. The swap row is locked for
        update for the duration of the request.

        This endpoint is **signet-only** (Kontor runs on signet); on any other
        network it returns `404`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Atomic swap id (`listing_type` must be `"kontor"`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [buyer_address, tx_id]
              properties:
                buyer_address:
                  type: string
                tx_id:
                  type: string
            example:
              buyer_address: bc1p…
              tx_id: aabbcc…
      responses:
        "200":
          description: Kontor swap recorded.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    properties:
                      buyer_address: { type: string }
                      tx_id: { type: string }
                      atomic_swap:
                        type: object
                        properties:
                          id: { type: string }
              example:
                data:
                  buyer_address: bc1p…
                  tx_id: aabbcc…
                  atomic_swap:
                    id: swap_abc
        "400":
          description: |
            Not a kontor listing, or invalid / unverified `tx_id` (transaction not
            found, or does not spend the listing escrow). Also returned when the swap
            is **not purchasable**: already filled, flagged anomalous, not yet
            confirmed, or a purchase is already pending for this listing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                notKontor:
                  value:
                    error: This endpoint is only for kontor listings
                badTx:
                  value:
                    error: "Invalid kontor swap transaction: transaction fake… does not spend escrow abc…:0"
                alreadyFilled:
                  value:
                    error: Atomic swap already filled
                pending:
                  value:
                    error: A purchase is already pending for this listing
        "404":
          description: |
            Atomic swap not found, or the swap is delisted (delisted swaps can only
            be purchased by the seller). On a non-signet network the endpoint is
            disabled and returns a plain-text `404 Not found` (no JSON `{ error }`
            body) — Kontor runs on signet only.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Atomic swap not found

  /api/atomic-swaps/{id}/delist-requests:
    post:
      tags: [Atomic Swaps]
      operationId: startDelistRequest
      summary: Start a delist request
      description: |
        Creates a delist request and returns its `id`. The client must then
        BIP322-sign that `id` string with the seller address and submit the
        signature via `PUT /api/atomic-swaps/delist-requests/{request_id}`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Atomic swap id to delist.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example: {}
      responses:
        "201":
          description: Delist request created.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id, atomic_swap]
                    properties:
                      id: { type: string }
                      atomic_swap:
                        type: object
                        properties:
                          id: { type: string }
                          seller_address: { type: string }
              example:
                data:
                  id: req_abc…
                  atomic_swap:
                    id: swap_abc
                    seller_address: bc1q…
        "400":
          description: Swap not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: AtomicSwap with id swap_abc not found

  /api/atomic-swaps/delist-requests/{request_id}:
    put:
      tags: [Atomic Swaps]
      operationId: confirmDelistRequest
      summary: Confirm a delist request
      description: Verifies the BIP322 signature and marks the underlying swap delisted.
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string }
          description: Delist request id from the start step.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature]
              properties:
                signature:
                  type: string
            example:
              signature: AkcwRAIg…
      responses:
        "201":
          description: Signature verified; swap delisted.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [id, signature]
                    properties:
                      id: { type: string }
                      signature: { type: [string, "null"] }
              example:
                data:
                  id: req_abc…
                  signature: AkcwRAIg…
        "400":
          description: Could not validate the message signature.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Could not validate message signature

  /api/bitcoin/address/{address}/utxos:
    get:
      tags: [Bitcoin]
      operationId: listAddressUtxos
      summary: List UTXOs for an address
      description: |
        Returns the unspent outputs held by a Bitcoin address, in Esplora/electrs
        shape. The result is sourced from the `ord` indexer and falls back to
        electrs if `ord` is unavailable; either way the JSON **shape** is identical.

        **Status caveat:** the two sources do not carry the same `status` *values*.
        The `ord` indexer (the default source) is a **confirmed-only** index, so
        every entry it returns is a confirmed UTXO — `status.confirmed: true` is
        accurate — but ord's output API does not carry block metadata, so the block
        fields come back as placeholders (`block_height: 0`, `block_hash: ""`,
        `block_time: 0`). Because ord ignores the mempool, it also will **not**
        reflect a UTXO already spent by an *unconfirmed* transaction (it still lists
        it as available). The electrs fallback is mempool-aware: it returns real
        block metadata and can additionally surface unconfirmed UTXOs
        (`status.confirmed: false`). Treat the `ord` block fields as unavailable, and
        prefer electrs when you need mempool-fresh funding inputs.

        Only the **address** is ever sent — never a key. Clients that fund a
        transaction themselves use this to pick spendable inputs; the Kontor SDK
        funding path calls it to gather a taproot address's confirmed UTXOs
        (filtering on `status.confirmed`). Responses carry
        `Cache-Control: public, max-age=30, stale-while-revalidate=60`.
      parameters:
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: Bitcoin address to list UTXOs for.
      responses:
        "200":
          description: UTXOs held by the address.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Utxo' }
              example:
                data:
                  - txid: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
                    vout: 0
                    value: 12000
                    status:
                      confirmed: true
                      block_height: 870123
                      block_hash: 0000000000000000000…
                      block_time: 1733080920

  /api/bitcoin/tx/{txid}:
    get:
      tags: [Bitcoin]
      operationId: getRawTransaction
      summary: Get a raw transaction
      description: |
        Returns the transaction identified by `txid` in **bitcoind JSON-RPC**
        verbose shape (`getrawtransaction <txid> true`): `txid`, `hash`, `version`,
        `size`, `vsize`, `weight`, `locktime`, `vin[]`, `vout[]` (outputs carry
        `scriptPubKey`), plus `confirmations` / `blockhash` / `time` once mined. This
        is **not** the Esplora shape — there is no `status` object and no
        `vin[].prevout`. Useful for confirming a broadcast or inspecting an escrow
        spend. Responses carry
        `Cache-Control: public, max-age=60, stale-while-revalidate=120`.
      parameters:
        - name: txid
          in: path
          required: true
          schema: { type: string }
          description: Transaction id.
      responses:
        "200":
          description: Transaction detail (bitcoind JSON-RPC verbose object).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    description: bitcoind `getrawtransaction … true` verbose transaction object.
                    additionalProperties: true

  /api/bitcoin/tx:
    post:
      tags: [Bitcoin]
      operationId: broadcastTransaction
      summary: Broadcast a signed transaction
      description: |
        Broadcasts a finalized, signed transaction to the network. Unlike the
        atomic-swap write endpoints, this route reads the **wrapped** body shape:
        the fields live under a top-level `data` object. `action_id` is optional and
        only used to attribute reward-eligible actions (e.g. `"mint-zeld"`); it does
        not affect the broadcast.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [data]
              properties:
                data:
                  type: object
                  required: [tx_hex]
                  properties:
                    tx_hex:
                      type: string
                      description: Finalized signed transaction hex.
                    action_id:
                      type: string
                      description: Optional reward-action attribution key.
            example:
              data:
                tx_hex: 0200000001…
      responses:
        "201":
          description: Broadcast accepted.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [tx_id]
                    properties:
                      tx_id: { type: string }
              example:
                data:
                  tx_id: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
        "400":
          description: |
            Invalid body — `tx_hex` (or the `data` wrapper) missing, or a
            structurally-invalid `tx_hex` (non-hex / unparseable) that cannot be
            decoded into a transaction (`Invalid transaction hex`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: Invalid transaction hex
        "500":
          description: |
            Network rejected the broadcast of an otherwise well-formed transaction
            (e.g. already spent, non-final, or fee too low).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: authjs.session-token
      description: |
        Optional Auth.js session cookie. Never required for trade-critical
        endpoints — the on-chain signature is the authentication. When present and
        the account carries an active subscription or credits, the platform fee is
        waived on `sell-quotes` / create. `fee-quotes` has **no** waiver path and
        ignores the session entirely — account holders skip that call instead.

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable failure reason.

    ListingType:
      type: string
      enum: [counterparty, ordinal, zeld, kontor]
      description: |
        Asset family of a listing:
        * `counterparty` — Counterparty asset.
        * `ordinal` — Bitcoin Ordinal / inscription.
        * `zeld` — Zeld asset.
        * `kontor` — Kontor KOR token or Kontor NFT.

    Pagination:
      type: object
      required: [total, offset]
      properties:
        total:
          type: integer
          description: Total number of matching rows.
        offset:
          type: integer
          description: Offset of the first row in the returned page.
        limit:
          type: [integer, "null"]
          description: Page size requested; `null` if unbounded.

    OnChainPayment:
      type: object
      required: [id, confirmed, sats, to_address]
      properties:
        id:
          type: string
        confirmed:
          type: boolean
        txid:
          type: [string, "null"]
        sats:
          type: integer
          format: int64
          description: Platform-fee amount in sats.
        to_address:
          type: string
          description: Platform-fee destination address.

    Utxo:
      type: object
      description: Unspent transaction output in Esplora/electrs shape.
      required: [txid, vout, value, status]
      properties:
        txid:
          type: string
        vout:
          type: integer
        value:
          type: integer
          format: int64
          description: Output value in sats.
        status:
          type: object
          required: [confirmed]
          properties:
            confirmed:
              type: boolean
            block_height:
              type: [integer, "null"]
            block_hash:
              type: [string, "null"]
            block_time:
              type: [integer, "null"]

    PendingSale:
      type: object
      required: [tx_id, buyer_address, atomic_swap]
      properties:
        tx_id:
          type: string
          description: Broadcast transaction id.
        buyer_address:
          type: string
        atomic_swap:
          type: object
          required: [id]
          properties:
            id:
              type: string

    AtomicSwap:
      type: object
      required:
        - id
        - listing_type
        - seller_address
        - asset_utxo_id
        - asset_utxo_value
        - price
        - funded
        - filled
        - confirmed
        - delisted
        - seller_delisted
        - expired
        - pending
        - anomalous
        - created_at
        - updated_at
      properties:
        id:
          type: string
        listing_type:
          $ref: '#/components/schemas/ListingType'
        seller_address:
          type: string
        buyer_address:
          type: [string, "null"]
          description: Set once a buyer has filled the swap.
        asset_utxo_id:
          type: string
          description: The UTXO that holds the listed asset.
        asset_utxo_value:
          type: integer
          format: int64
          description: Sats locked in the asset UTXO.
        asset_name:
          type: [string, "null"]
        asset_quantity:
          type: [string, "null"]
          description: |
            Quantity in the asset's smallest units, as an integer-valued **decimal
            string** (e.g. `"100000000"`). Returned as a string — not a JSON number —
            to preserve full precision for large divisible / ZELD amounts beyond
            2^53. `null` for listings without a quantity (e.g. ordinals). On the
            **request** side (`sell-quotes` / create) a plain integer is still
            accepted as well as a string.
        asset_divisibility:
          type: [boolean, "null"]
          description: Whether the listed Counterparty asset is divisible. `null` for `ordinal` / `zeld` listings and legacy rows not yet backfilled.
        price:
          type: integer
          format: int64
          description: Net sats the seller receives (the swap PSBT's seller-payment output). For royalty assets the buyer pays `price` + royalty on top.
        price_per_unit:
          type: [integer, "null"]
          format: int64
        psbt_hex:
          type: [string, "null"]
          description: |
            Seller-signed PSBT. `null` until the swap is funded. Returned by the
            single-swap detail (`GET /api/atomic-swaps/{id}`) only — **omitted**
            (key absent) from the list endpoint `GET /api/atomic-swaps`.
        tx_id:
          type: [string, "null"]
          description: Fill transaction id, once a buyer has been confirmed.
        block_index:
          type: [integer, "null"]
          description: Tip height at listing time.
        funded:
          type: boolean
          description: Whether the seller's asset UTXO is confirmed on-chain.
        filled:
          type: boolean
        confirmed:
          type: boolean
          description: Whether the on-chain platform fee has confirmed.
        delisted:
          type: boolean
        seller_delisted:
          type: boolean
        expired:
          type: boolean
        pending:
          type: boolean
          description: A buy tx for this swap is in the mempool.
        anomalous:
          type: boolean
          description: Anti-fraud flag; non-purchasable when `true`.
        pending_role:
          type: [string, "null"]
          enum: [seller, buyer, null]
          description: |
            Populated **only** on the list endpoint (`GET /api/atomic-swaps`) when
            the request supplies `pending_address`, and only for that address's
            in-progress orders: `"seller"` when this swap is one of the address's
            pending sell orders (its own listing still settling on-chain), `"buyer"`
            when the address has an in-flight (broadcast, not yet confirmed) purchase
            of this swap. `null` for every other item and whenever `pending_address`
            is not supplied.
        pending_txid:
          type: [string, "null"]
          description: |
            The still-unconfirmed Bitcoin transaction id for this pending order —
            e.g. to build a `mempool.space/tx/{txid}` link. Set alongside
            `pending_role`: for a `"seller"` item it is the listing's own
            unconfirmed tx (the attach / asset-UTXO tx while `confirmed` is `false`,
            otherwise the on-chain fee tx); for a `"buyer"` item it is the address's
            in-flight buy tx. `null` when `pending_role` is `null`.
        royalty:
          type: [number, "null"]
          description: Royalty percentage taken by the asset issuer, if any.
        image_url:
          type: [string, "null"]
          description: Full-size image URL for the listed asset.
        thumbnail_url:
          type: [string, "null"]
          description: Thumbnail image URL. Same source as `image_url`.
        inscription_number:
          type: [integer, "null"]
          description: Sequential inscription number for `ordinal` listings. `null` for all other types.
        kontor_offer_blob:
          type: [string, "null"]
          description: 'Raw `OfferData` JSON blob produced by `Attachment.offer()`. Only set for `listing_type: "kontor"`.'
        kontor_asset_kind:
          type: [string, "null"]
          description: '`"token"` or `"nft"`. Only set for `listing_type: "kontor"`.'
        kontor_contract_address:
          type: [string, "null"]
          description: 'Kontor contract address (`"token@0.0"` for KOR, or the NFT contract address). Only set for `listing_type: "kontor"`.'
        kontor_nft_id:
          type: [string, "null"]
          description: Kontor NFT id. Set only for `kontor` NFT listings.
        kontor_amount:
          type: [string, "null"]
          description: KOR token amount as a positive decimal string. Set only for `kontor` token listings.
        expires_at:
          type: [string, "null"]
          format: date-time
          description: RFC 3339.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        on_chain_payment:
          description: |
            Platform-fee payment record. Returned by the single-swap detail
            (`GET /api/atomic-swaps/{id}`) only — **omitted** (key absent) from the
            list endpoint `GET /api/atomic-swaps`. The create response embeds a
            reduced form (`OnChainPaymentSummary`, no `sats` / `to_address`).
          oneOf:
            - $ref: '#/components/schemas/OnChainPayment'
            - type: "null"
        user:
          type: [object, "null"]
          properties:
            id:
              type: string

    OnChainPaymentSummary:
      type: object
      description: |
        Reduced platform-fee payment embedded in the **create** response. Unlike the
        full `OnChainPayment` (returned by the single-swap detail), it omits `sats`
        and `to_address`.
      required: [id, confirmed]
      properties:
        id:
          type: string
        confirmed:
          type: boolean
        txid:
          type: [string, "null"]

    AtomicSwapCreated:
      type: object
      description: |
        Shape returned by `POST /api/atomic-swaps` for **both** the `201` (created)
        and the `200` (idempotent replay). It is a **subset** of `AtomicSwap`: the
        trade-state flags that are always at their initial value right after creation
        (`funded`, `delisted`, `seller_delisted`, `pending`, `anomalous`) and the
        read-only enrichments (`royalty`, `price_per_unit`, `block_index`, `user`)
        are **not** returned here — read them later via
        `GET /api/atomic-swaps/{id}`. The embedded `on_chain_payment` is the reduced
        `OnChainPaymentSummary` (no `sats` / `to_address`).
      required:
        - id
        - listing_type
        - seller_address
        - asset_utxo_id
        - asset_utxo_value
        - price
        - filled
        - confirmed
        - expired
        - created_at
        - updated_at
      properties:
        id:
          type: string
        listing_type:
          $ref: '#/components/schemas/ListingType'
        seller_address:
          type: string
        buyer_address:
          type: [string, "null"]
        asset_utxo_id:
          type: string
        asset_utxo_value:
          type: integer
          format: int64
        asset_name:
          type: [string, "null"]
        asset_quantity:
          type: [string, "null"]
          description: Integer-valued decimal string in the asset's smallest units (preserves >2^53 precision). `null` when absent.
        asset_divisibility:
          type: [boolean, "null"]
        price:
          type: integer
          format: int64
        psbt_hex:
          type: [string, "null"]
          description: Seller-signed swap PSBT (echoed back). `null` for `kontor` listings.
        tx_id:
          type: [string, "null"]
        filled:
          type: boolean
        confirmed:
          type: boolean
        expired:
          type: boolean
        inscription_number:
          type: [integer, "null"]
        image_url:
          type: [string, "null"]
        thumbnail_url:
          type: [string, "null"]
        kontor_offer_blob:
          type: [string, "null"]
        kontor_asset_kind:
          type: [string, "null"]
        kontor_contract_address:
          type: [string, "null"]
        kontor_nft_id:
          type: [string, "null"]
        kontor_amount:
          type: [string, "null"]
        expires_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        on_chain_payment:
          oneOf:
            - $ref: '#/components/schemas/OnChainPaymentSummary'
            - type: "null"

    FeePayment:
      type: object
      description: |
        Platform-fee payment. Carries `psbt_hex` + `fee_payment_id` when paying
        from an existing asset UTXO; carries only `fee_payment_id` when the fee is
        folded into a `counterparty` attach or a `kontor` attach reveal.
      required: [fee_payment_id]
      properties:
        psbt_hex:
          type: string
          description: Signed fee PSBT (omitted when the fee is folded into a prep / attach tx).
        fee_payment_id:
          type: string

    ZeldPayment:
      type: object
      description: For `zeld` transfer-prep listings — the finalized signed transfer tx that carries both the isolated ZELD UTXO and the platform-fee BTC output.
      required: [zeld_send_txid, zeld_send_tx_hex, fee_payment_id]
      properties:
        zeld_send_txid:
          type: string
        zeld_send_tx_hex:
          type: string
          description: The **finalized** signed prep tx hex.
        fee_payment_id:
          type: string

    CreateAtomicSwapRequest:
      type: object
      required: [asset_utxo_id, asset_utxo_value, price, seller_address]
      properties:
        asset_utxo_id:
          type: string
          description: '`txid:vout` of the asset UTXO (from `sell-quotes`).'
        asset_utxo_value:
          type: integer
          format: int64
          description: Sats in the asset UTXO (from `sell-quotes`).
        asset_name:
          type: string
          description: Required for `counterparty` / `zeld` (must be `"ZELD"` for zeld).
        asset_quantity:
          oneOf:
            - type: integer
              format: int64
            - type: string
          description: |
            Quantity in the asset's smallest units. May be sent as a JSON integer
            or — to avoid 2^53 precision loss on large divisible / ZELD amounts —
            as an integer **string**. Both forms are accepted; validation compares
            via `BigInt`, and the value is stored and returned as an integer-valued
            decimal string to preserve full precision.
        price:
          type: integer
          format: int64
          description: Net sats the seller receives.
        seller_address:
          type: string
        psbt_hex:
          type: [string, "null"]
          description: The signed `swap_psbt`. Not required for `kontor` listings (set null / omit).
        listing_type:
          $ref: '#/components/schemas/ListingType'
          description: Defaults to `counterparty` when omitted.
        expires_at:
          type: string
          format: date-time
          description: RFC 3339. Omit for a non-expiring listing.
        funding_tx_hex:
          type: string
          description: |
            Signed prep tx (the attach **commit** for `counterparty`). For a `zeld`
            transfer prep it is used **only when the fee is waived** — it then carries
            the finalized transfer tx; an on-chain-paid `zeld` transfer uses
            `zeld_payment.zeld_send_tx_hex` instead.
        reveal_tx_hex:
          type: string
          description: Pre-signed reveal tx from the quote, broadcast after the attach commit when present.
        fee_payment:
          $ref: '#/components/schemas/FeePayment'
        zeld_payment:
          $ref: '#/components/schemas/ZeldPayment'
        kontor_offer_blob:
          type: string
          description: Raw JSON string from `offer.serialize()`. `kontor` only.
        kontor_asset_kind:
          type: string
          enum: [token, nft]
        kontor_contract_address:
          type: string
          description: '`"token@0.0"` or the NFT contract address.'
        kontor_nft_id:
          type: string
          description: NFT id (omit for token listings).
        kontor_amount:
          type: string
          description: Positive decimal amount string (omit for NFT listings).

    SellQuoteRequest:
      type: object
      required: [price, seller_address]
      properties:
        asset_utxo_id:
          type: string
          description: Existing asset UTXO (`txid:vout`). Omit to compose a prep tx. Required for `ordinal`.
        asset_name:
          type: string
          description: Required for `counterparty` (and `"ZELD"` for `zeld`).
        asset_quantity:
          oneOf:
            - type: integer
              format: int64
            - type: string
          description: |
            Must be `1` or omitted for `ordinal`. May be a JSON integer or — to
            avoid 2^53 precision loss on large divisible / ZELD amounts — an integer
            **string**.
        price:
          type: integer
          format: int64
        seller_address:
          type: string
        seller_pubkey:
          type: string
          description: Required when `seller_address` is P2TR (taproot internal key).
        listing_type:
          type: string
          enum: [counterparty, ordinal, zeld]
          default: counterparty
          description: |
            Defaults to `counterparty` when omitted. `kontor` is **not** accepted
            here — kontor listings do not use `sell-quotes` (they go through the
            Kontor SDK); passing `kontor` (or any other out-of-enum value) is
            rejected with a generic `400` schema-validation error, not a
            kontor-specific message.
        sats_per_vbyte:
          type: number
          description: Feerate target. Defaults to the current half-hour feerate.
        auto_select_fee_utxos:
          type: boolean
          description: Mutually exclusive with `fee_utxo_ids`.
        fee_utxo_ids:
          type: array
          items: { type: string }
          description: Specific fee/funding UTXOs. Asset-bearing ids are rejected. Mutually exclusive with `auto_select_fee_utxos`.
        preview:
          type: boolean
          description: Compute the cost breakdown without persisting an `OnChainPayment`. Returned PSBTs must not be signed.

    SellQuoteResult:
      type: object
      required: [swap_psbt, swap_inputs_to_sign, fee_waived, asset_utxo_id, asset_utxo_value]
      properties:
        swap_psbt:
          type: string
        swap_inputs_to_sign:
          type: array
          items: { type: integer }
        fee_psbt:
          type: [string, "null"]
          description: '`null` when the fee is folded into a prep tx or waived.'
        fee_inputs_to_sign:
          type: array
          items: { type: integer }
        fee_payment_id:
          type: [string, "null"]
          description: '`null` when the fee is waived.'
        fee_waived:
          type: boolean
        asset_utxo_id:
          type: string
        asset_utxo_value:
          type: integer
          format: int64
        prep_psbt:
          type: [string, "null"]
        prep_inputs_to_sign:
          type: array
          items: { type: integer }
        prep_kind:
          type: [string, "null"]
          enum: [zeld_transfer, attach, null]
        reveal_tx_hex:
          type: string
          description: Pre-signed reveal tx (only for an attach envelope). When set, `asset_utxo_id` is `{reveal_txid}:0`.
        payment_address:
          type: string
          description: Platform-fee destination when the fee is folded into the prep tx.
        payment_amount:
          type: integer
          format: int64
          description: Folded platform-fee output amount in sats.
        listing_fee_sats:
          type: [integer, "null"]
          format: int64
          description: Platform fee. `null` when waived.
        attach_fee_sats:
          type: [integer, "null"]
          format: int64
          description: Miner fee of the prep tx. `null` when an existing UTXO is reused.
        network_fee_sats:
          type: [integer, "null"]
          format: int64
          description: Miner fee of the standalone platform-fee tx. `null` when folded into the prep tx or waived.

    FeeQuoteRequest:
      type: object
      required: [address]
      properties:
        type:
          type: string
          enum: [zeld, kontor]
          description: Set for the `zeld` / `kontor` output-style variants. Omit for the `counterparty` / `ordinal` PSBT variant.
        address:
          type: string
        utxo_set_ids:
          type: array
          items: { type: string }
          description: |
            UTXOs to pay the fee from. **Required** for the `counterparty` /
            `ordinal` PSBT variant (alongside `sats_per_vbyte`) — it has no
            server-side default, so omitting it returns `400`. Ignored for the
            `zeld` / `kontor` output variants.
        sats_per_vbyte:
          type: number
          description: |
            Feerate target for the PSBT (`counterparty` / `ordinal`) variant.
            **Required** there — it has no server-side default, so omitting it returns
            `400`. Ignored for the `zeld` / `kontor` output variants.

    FeeQuoteResult:
      type: object
      required: [fee_payment_id]
      properties:
        fee_payment_id:
          type: string
        psbt:
          type: string
          description: Unsigned BTC fee PSBT (PSBT variant).
        raw_transaction:
          type: string
          description: Raw unsigned tx (PSBT variant).
        inputs_to_sign:
          type: array
          items: { type: integer }
          description: PSBT variant.
        payment_address:
          type: string
          description: Platform-fee destination (output variant).
        payment_amount:
          type: integer
          format: int64
          description: BTC amount in sats to include as an extra output (output variant).

    BuyQuoteRequest:
      type: object
      required: [swap_ids, buyer_address]
      properties:
        swap_ids:
          type: array
          items: { type: string }
          description: All must reference the same asset. Exactly one for `ordinal`.
        buyer_address:
          type: string
          description: Must be P2WPKH.
        buyer_taproot_address:
          type: string
          description: Required for `ordinal` purchases (receives the inscription).
        sats_per_vbyte:
          type: number
          description: Defaults to the current half-hour feerate.
        detach:
          type: boolean
          description: Defaults to `true`. Ignored for `zeld`.
        auto_select:
          type: boolean
          description: |
            Auto-selection of funding UTXOs is already the **default** whenever
            `funding_utxo_ids` is omitted, so this flag is normally unnecessary. Its
            only effect is to be the mutually-exclusive counterpart of
            `funding_utxo_ids`: supplying both returns `400`.
        funding_utxo_ids:
          type: array
          items: { type: string }
          description: Specific funding UTXOs. Asset-bearing ids are rejected. Mutually exclusive with `auto_select`.

    BuyQuoteResult:
      type: object
      required: [psbt, inputs_to_sign, fee_estimate_sats, royalty_sats, royalty_address]
      properties:
        psbt:
          type: string
        inputs_to_sign:
          type: array
          items: { type: integer }
        fee_estimate_sats:
          type: integer
          format: int64
        royalty_sats:
          type: integer
          format: int64
          description: '`0` when below the 546-sat dust limit or for `ordinal` listings.'
        royalty_address:
          type: [string, "null"]
