openapi: 3.0.3
info:
  title: Twin Bridge — VASP-side API (reverse contract)
  version: 1.2.0
  description: |
    Контракт для интеграции VASP с Twin Bridge в нашей модели —
    **по умолчанию мы пишем адаптер под существующий API партнёра**
    (см. `docs/INTEGRATION_POLICY.md`). Этот документ описывает то,
    что мы ожидаем концептуально, и используется как справка, когда
    VASP пишет свой API с нуля и хочет соответствовать нашей форме.

    Prose walkthrough: `docs/twin-bridge-exchanger-api-spec.md`.

    ## Phase 1: split-custody model (что реализовано в коде)

    Provider держит USDT-custody клиента, VASP держит только KGS.
    Поэтому VASP в Phase 1 нужны **только KGS-операции**:

    | Method | When |
    |---|---|
    | `GET  /v1/health` | liveness probe |
    | `POST /v1/quote` | get the live KGS↔USDT rate |
    | `POST /v1/qr` | mint an ELQR QR for a customer KGS deposit (ON_RAMP) |
    | `POST /v1/kgs-payout` | pay KGS out to a customer (OFF_RAMP) |
    | `GET  /v1/tx/{external_tx_id}` | reconciliation polling on UNKNOWN |

    USDT settlement between Provider and VASP is **off-chain in batches**
    and lives outside Twin Bridge — TB only provides the audit trail.

    Phase 2+ optional: a Full-mode VASP that also holds USDT custody (for
    neobank-style providers) would extend this contract via an
    `VASPFullAdapter`. Not implemented today.

    ## Provider attribution (правило для всех наших вызовов к VASP)

    В каждом запросе от Twin Bridge к VASP поле `transaction_id` содержит
    префикс с slug провайдера-источника:

    ```
    transaction_id = "tb-<provider_slug>-<uuid>"
    ```

    Примеры:
    - `tb-guardarian-550e8400-e29b-41d4-a716-446655440000`
    - `tb-moonpay-abc12300-e29b-41d4-a716-446655440000`
    - `tb-neobank-kg-def45600-e29b-41d4-a716-446655440000`

    **Что VASP делает:** парсит префикс, знает counterparty-провайдера,
    сохраняет в своей БД (поле `agent_transaction_id`), использует для
    Travel Rule (>85k KGS) и FinSupervision reporting (>100k KGS).

    **Что VASP НЕ должен делать:** не требовать отдельного поля `provider_id`
    в API, не валидировать формат строго — набор provider_slug'ов добавляется
    по мере подключения.

    ## Authentication

    Inbound authentication mirrors the outbound Twin Bridge API:
    ```
    body_hash  = sha256_hex(raw_body)
    message    = timestamp + METHOD + PATH + body_hash
    signature  = hmac_sha256(shared_secret, message)
    ```
    Reference: `site/docs/samples/hmac-sign.{go,js,php}`.

    ## Каналы attribution — полная картина

    | Канал | Содержимое | Использование |
    |---|---|---|
    | `transaction_id` префикс | `tb-<provider_slug>-<uuid>` | Быстрая машинная привязка per-tx |
    | `account` поле | ID клиента у провайдера (`provider_slug:user_id`) | Линк на end-user у provider'а |
    | `kyc_share_token` | Sumsub applicant reference | Canonical источник для compliance reports |
    | VASP-кабинет Twin Bridge | Provider name/slug, amount, KYC, events, фильтры | Операционная работа, support, диспуты |
    | Settlement invoice | Breakdown per-provider | Бухгалтерия, accounting |

servers:
  - url: https://api.your-vasp.example
    description: Production
  - url: https://sandbox.your-vasp.example
    description: Sandbox (pre-onboarding)

tags:
  - name: Quoting
  - name: OnRamp
  - name: OffRamp
  - name: Reconciliation
  - name: Health

components:
  securitySchemes:
    TwinBridgeHMAC:
      type: apiKey
      in: header
      name: X-Signature
      description: |
        HMAC-SHA256 over `timestamp + METHOD + PATH + sha256_hex(body)`.
        Always arrives together with `X-API-Key`, `X-Timestamp`, `Idempotency-Key`.

  parameters:
    XApiKey:        { name: X-API-Key,       in: header, required: true, schema: { type: string } }
    XSignature:     { name: X-Signature,     in: header, required: true, schema: { type: string } }
    XTimestamp:     { name: X-Timestamp,     in: header, required: true, schema: { type: string } }
    IdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, format: uuid } }

  schemas:
    Decimal:
      type: string
      example: "5000.00"
      description: Decimal number as string to avoid float precision issues.

    Direction:
      type: string
      enum: [ON_RAMP, OFF_RAMP]

    QuoteRequest:
      type: object
      required: [direction, fiat_currency, crypto_currency]
      properties:
        direction:       { $ref: '#/components/schemas/Direction' }
        fiat_currency:   { type: string, example: KGS }
        crypto_currency: { type: string, example: USDT }
        fiat_amount:     { $ref: '#/components/schemas/Decimal' }
        crypto_amount:   { $ref: '#/components/schemas/Decimal' }

    QuoteResponse:
      type: object
      required: [quote_id, rate, fiat_amount, crypto_amount, expires_at, available]
      properties:
        quote_id:      { type: string, example: q_7f3d2a1b }
        rate:          { $ref: '#/components/schemas/Decimal' }
        fiat_amount:   { $ref: '#/components/schemas/Decimal' }
        crypto_amount: { $ref: '#/components/schemas/Decimal' }
        fee:           { $ref: '#/components/schemas/Decimal' }
        fee_currency:  { type: string, example: KGS }
        expires_at:    { type: string, format: date-time }
        available:     { type: boolean }

    QRRequest:
      type: object
      required: [tx_id, fiat_amount, fiat_currency]
      properties:
        tx_id:
          type: string
          description: |
            Twin Bridge tx_id. Convention: `tb-<provider_slug>-<uuid>`
            so the VASP can read the source provider from the prefix
            (Travel Rule, FinSupervision reporting, dashboard split).
          example: tb-guardarian-550e8400-e29b-41d4-a716-446655440000
        fiat_amount:     { $ref: '#/components/schemas/Decimal' }
        fiat_currency:   { type: string, example: KGS }
        ttl_seconds:     { type: integer, nullable: true, description: "QR lifetime; omit to use the VASP default" }
        client_account:  { type: string, nullable: true, description: "Stable client identifier inside the provider — for tx ↔ user joins on the VASP side" }
        kyc_share_token: { type: string, nullable: true }
        kyc_data:        { $ref: '#/components/schemas/KYCData', nullable: true }

    QRResponse:
      type: object
      required: [qr_data, expires_at, amount, currency]
      properties:
        qr_data:    { type: string, description: Raw EMV QR (ELQR format) }
        qr_url:     { type: string, format: uri, nullable: true }
        amount:     { $ref: '#/components/schemas/Decimal' }
        currency:   { type: string }
        expires_at: { type: string, format: date-time }

    KGSPayoutRequest:
      type: object
      required: [tx_id, kgs_amount, idempotency_key]
      properties:
        tx_id:
          type: string
          description: "Twin Bridge tx_id, same `tb-<provider_slug>-<uuid>` convention as QRRequest."
        kgs_amount:           { $ref: '#/components/schemas/Decimal' }
        recipient_kgs_wallet: { type: string, nullable: true, description: "One of recipient_kgs_wallet / recipient_phone is required" }
        recipient_phone:      { type: string, nullable: true }
        idempotency_key:      { type: string, format: uuid }
        kyc_share_token:      { type: string, nullable: true }
        kyc_data:             { $ref: '#/components/schemas/KYCData', nullable: true }

    KYCData:
      type: object
      description: |
        Full client PII — passed only when the VASP advertises
        `SupportsKYC() == true` to Twin Bridge. TB stores these
        fields encrypted (AES-256-GCM, TTL 24h) and forwards them at
        GenerateQR / ExecuteKGSPayout time. The VASP must treat them
        as sensitive PII per its own AML/KYC obligations.
      properties:
        full_name:     { type: string }
        email:         { type: string, format: email }
        date_of_birth: { type: string, example: "1990-01-31" }
        country:       { type: string, description: "ISO 3166-1 alpha-2", example: KG }
        share_token:   { type: string, description: "Sumsub passthrough" }

    ExecutionResult:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ACCEPTED, EXECUTED, REJECTED]
        external_tx_id: { type: string, nullable: true }
        reason:         { type: string, nullable: true }

    TxStatus:
      type: object
      required: [external_tx_id, status]
      properties:
        external_tx_id: { type: string }
        status:
          type: string
          enum: [PENDING, COMPLETED, FAILED, NOT_FOUND]
        settled_at: { type: string, format: date-time, nullable: true }

    Health:
      type: object
      required: [alive, pairs]
      properties:
        alive:      { type: boolean }
        pairs:      { type: array, items: { type: string }, example: ["KGS/USDT"] }
        latency_ms: { type: integer }

    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string }
        code:  { type: string, example: PAIR_UNAVAILABLE }

security:
  - TwinBridgeHMAC: []

paths:
  /v1/health:
    get:
      tags: [Health]
      summary: Liveness + supported pairs
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

  /v1/quote:
    post:
      tags: [Quoting]
      summary: Price a transaction
      description: |
        Rate Engine calls this in parallel across all VASPs. Keep p95 latency
        under 500ms or scoring penalises you.
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QuoteRequest' }
      responses:
        '200':
          description: Quote
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuoteResponse' }
        '422':
          description: Pair unavailable / no liquidity
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/qr:
    post:
      tags: [OnRamp]
      summary: Create ELQR QR (ON_RAMP)
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/QRRequest' }
      responses:
        '200':
          description: QR payload
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QRResponse' }

  /v1/kgs-payout:
    post:
      tags: [OffRamp]
      summary: Send KGS to end user after USDT arrives (OFF_RAMP)
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/KGSPayoutRequest' }
      responses:
        '200':
          description: Accepted or rejected
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExecutionResult' }

  /v1/tx/{external_tx_id}:
    get:
      tags: [Reconciliation]
      summary: Poll status (used when webhook is missing)
      parameters:
        - $ref: '#/components/parameters/XApiKey'
        - $ref: '#/components/parameters/XSignature'
        - $ref: '#/components/parameters/XTimestamp'
        - name: external_tx_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TxStatus' }
        '404':
          description: Unknown tx
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
