openapi: 3.1.0
info:
  title: UNYKORN VAULT — control plane and signer
  version: 1.0.0
  description: |
    Two loopback services. `uny-api` (control plane, holds no keys) and `uny-signer`
    (one per key set). Amounts are integer minor units as decimal strings.
    Prose reference: docs/dev/API.md.
servers:
  - url: http://127.0.0.1:7331
    description: uny-api
  - url: http://127.0.0.1:7333
    description: uny-signer (signer-a)
components:
  securitySchemes:
    session:
      type: http
      scheme: bearer
      description: Token from POST /session/unlock.
    signerToken:
      type: apiKey
      in: header
      name: x-uny-signer-token
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: integer, description: "4000 bad request · 4100 session · 4040 not found · -32602 refused · -32603 internal" }
            message: { type: string }
            data: { nullable: true }
    Operation:
      description: What the intent asks the ledger to do. Tagged by `op`.
      oneOf:
        - type: object
          properties: { op: { const: transfer } }
        - type: object
          properties: { op: { const: issue } }
        - type: object
          properties: { op: { const: authorize_holder }, revoke: { type: boolean, default: false } }
        - type: object
          properties: { op: { const: clawback } }
        - type: object
          required: [op, asset_scale, flags]
          properties:
            op: { const: mpt_create }
            asset_scale: { type: integer, minimum: 0, maximum: 19 }
            maximum_amount: { type: integer, nullable: true }
            transfer_fee: { type: integer, nullable: true, maximum: 50000 }
            flags: { type: integer, description: "0x02 CanLock 0x04 RequireAuth 0x08 CanEscrow 0x10 CanTrade 0x20 CanTransfer 0x40 CanClawback" }
            metadata_hex: { type: string, nullable: true }
        - type: object
          properties:
            op: { const: account_flags }
            set: { type: integer, nullable: true, description: "XRPL asf number / Stellar flag mask" }
            clear: { type: integer, nullable: true }
        - type: object
          required: [op, data_hex]
          properties: { op: { const: contract_call }, data_hex: { type: string, pattern: "^0x[0-9a-fA-F]{8,}$" } }
    AssetSpec:
      type: object
      required: [chain, symbol, decimals]
      properties:
        chain: { type: string, description: "serde chain name, e.g. xrpl-testnet" }
        symbol: { type: string }
        decimals: { type: integer }
        issuer: { type: string, nullable: true, description: "XRPL ACCOUNT.CODE or 48-hex MPT id · Stellar CODE:ISSUER · EVM contract" }
    IntentRequest:
      type: object
      required: [wallet, destination, amount]
      properties:
        wallet: { type: string, example: wal_treasury-xrp }
        destination: { type: string }
        amount: { type: string, description: "integer minor units", example: "1000000000" }
        asset_symbol: { type: string }
        asset_issuer: { type: string }
        asset_decimals: { type: integer, default: 6 }
        memo: { type: string }
        initiator: { type: string, default: operator }
        tags:
          type: array
          items: { type: array, items: { type: string }, minItems: 2, maxItems: 2 }
        operation: { $ref: "#/components/schemas/Operation" }
    Decision:
      type: object
      properties:
        decision: { type: string, enum: [allow, deny, require_approvals, delay] }
        reasons: { type: array, items: { type: string } }
        required_weight: { type: integer }
        current_weight: { type: integer }
        not_before: { type: integer }
    IntentStatus:
      type: string
      enum: [allowed, pending_approvals, timelocked, denied, submitted, confirmed, failed]
    SignedApproval:
      type: object
      required: [approver_id, intent, verdict, at, strong_auth, signature]
      properties:
        approver_id: { type: string }
        intent: { type: string, pattern: "^int_[0-9a-f]{64}$" }
        verdict: { type: string, enum: [approve, reject] }
        at: { type: integer }
        strong_auth: { type: boolean }
        signature:
          type: object
          properties:
            curve: { const: ed25519 }
            sig: { type: string, description: "64-byte hex" }
    Finding:
      type: object
      properties:
        guard: { type: string, enum: [network_separation, counterfeit_asset, address_poisoning, burned_key] }
        severity: { type: string, enum: [block, warn] }
        detail: { type: string }
    SignerOutcome:
      type: object
      properties:
        signer: { type: string }
        address: { type: string }
        ok: { type: boolean }
        detail: { type: string }
    Rule:
      type: object
      description: Tagged by `rule`. See docs/dev/POLICY.md for every variant.
      required: [rule]
      properties:
        rule:
          type: string
          enum: [always_allow, require_compliance_clearance, destination_allowlist, destination_blocklist, daily_velocity_cap, approval_threshold, timelock_above, hard_ceiling, require_attestation, require_human_approver_for_agent_initiated, agent_budget_ceiling, operating_window_utc, require_tag, allow_operations]
      additionalProperties: true
    PolicySet:
      type: object
      properties:
        wallet: { type: string }
        version: { type: integer }
        rules: { type: array, items: { $ref: "#/components/schemas/Rule" } }
    SignerDesc:
      type: object
      properties:
        address: { type: string }
        curve: { type: string, enum: [ed25519, secp256k1] }
        weight: { type: integer }
        label: { type: string }
    WalletDescriptor:
      type: object
      properties:
        wallet: { type: string }
        chain: { type: string }
        control:
          oneOf:
            - type: object
              properties:
                kind: { const: native_multisig }
                address: { type: string }
                signers: { type: array, items: { $ref: "#/components/schemas/SignerDesc" } }
                quorum: { type: integer }
            - type: object
              properties:
                kind: { const: evm_safe }
                safe: { type: string }
                owners: { type: array, items: { $ref: "#/components/schemas/SignerDesc" } }
                threshold: { type: integer }
    ChainParams:
      oneOf:
        - type: object
          properties: { kind: { const: xrpl }, sequence: { type: integer }, base_fee_drops: { type: integer }, last_ledger_sequence: { type: integer }, network_id: { type: integer, nullable: true } }
        - type: object
          properties: { kind: { const: stellar }, sequence: { type: integer }, base_fee_stroops: { type: integer }, max_time: { type: integer } }
        - type: object
          properties: { kind: { const: evm_safe }, chain_id: { type: integer }, safe_nonce: { type: string } }
    SignRequest:
      type: object
      required: [intent, wallet, policy, policy_digest, approvals, params, protect, expected_payload_digest, signer_address]
      properties:
        intent: { type: object, description: "TransferIntent" }
        wallet: { $ref: "#/components/schemas/WalletDescriptor" }
        policy: { $ref: "#/components/schemas/PolicySet" }
        policy_digest: { type: string }
        approvals: { type: array, items: { $ref: "#/components/schemas/SignedApproval" } }
        params: { $ref: "#/components/schemas/ChainParams" }
        protect:
          type: object
          properties:
            known_destinations: { type: array, items: { type: string } }
            burned: { type: array, items: { type: string } }
            evm_code: { type: array, items: { type: array } }
            token_owner_burned: { type: boolean }
            onchain_symbol: { type: string, nullable: true }
            compliance_cleared: { type: boolean }
            attestation_valid: { type: boolean }
        expected_payload_digest: { type: string }
        signer_address: { type: string }
    SignResponse:
      type: object
      properties:
        signature:
          type: object
          properties:
            signer_address: { type: string }
            public_key: { type: object }
            signature: { type: object }
        backend: { type: string, enum: [software, yubihsm] }
        receipt_head: { type: string }
        warnings: { type: array, items: { $ref: "#/components/schemas/Finding" } }
        summary: { type: string }
    SignerRefusal:
      type: object
      properties:
        refused:
          type: object
          properties:
            reason: { type: string, enum: [intent_tampered, policy_mismatch, approvals, rejected, policy_denied, protected, digest_mismatch, not_a_signer, key, chain] }
        message: { type: string }
paths:
  /health:
    get:
      summary: Liveness and receipt-chain state (both services, no auth)
      responses:
        "200": { description: "ok" }
  /session/unlock:
    post:
      summary: Unlock with the operator passphrase
      requestBody:
        content: { application/json: { schema: { type: object, properties: { passphrase: { type: string } } } } }
      responses:
        "200":
          description: session token
          content: { application/json: { schema: { type: object, properties: { token: { type: string }, expires_at: { type: integer }, accounts: { type: array, items: { type: string } } } } } }
  /session/lock:
    post: { summary: "Lock the session", security: [ { session: [] } ], responses: { "200": { description: "locked" } } }
  /wallets:
    get: { summary: "List wallets", security: [ { session: [] } ], responses: { "200": { description: "array of wallets" } } }
  /policy/{wallet}:
    get:
      summary: The policy set in force for a wallet
      security: [ { session: [] } ]
      parameters: [ { name: wallet, in: path, required: true, schema: { type: string } } ]
      responses: { "200": { description: "policy", content: { application/json: { schema: { $ref: "#/components/schemas/PolicySet" } } } } }
  /assets:
    get: { summary: "Canonical asset registry and reserved tickers", security: [ { session: [] } ], responses: { "200": { description: "registry" } } }
  /intent:
    post:
      summary: Create a content-addressed intent and evaluate policy
      security: [ { session: [] } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/IntentRequest" } } }
      responses:
        "200":
          description: created (or the original, on replay)
          content:
            application/json:
              schema:
                type: object
                properties:
                  intentId: { type: string }
                  status: { $ref: "#/components/schemas/IntentStatus" }
                  decision: { $ref: "#/components/schemas/Decision" }
                  receipt_head: { type: string }
        "400": { description: "bad request", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /intents:
    get: { summary: "List intents (newest first, 100 max)", security: [ { session: [] } ], responses: { "200": { description: "list" } } }
  /intent/{id}:
    get:
      summary: One intent with its decision, approvals and execution
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses: { "200": { description: "intent" } }
  /intent/{id}/protect:
    get:
      summary: Run the protections with live chain inputs, without executing
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: findings
          content: { application/json: { schema: { type: object, properties: { intentId: { type: string }, blocked: { type: boolean }, findings: { type: array, items: { $ref: "#/components/schemas/Finding" } } } } } }
  /intent/{id}/approve-signed:
    post:
      summary: Record a signed approval or rejection
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SignedApproval" } } } }
      responses:
        "200": { description: "status after re-evaluation; a rejection returns status denied and rejectedBy" }
        "400": { description: "unknown approver, wrong intent, or the initiator approving their own intent" }
  /intent/{id}/approve:
    post:
      summary: Legacy unsigned approval — signers do not count it
      deprecated: true
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses: { "200": { description: "recorded" } }
  /intent/{id}/execute:
    post:
      summary: "Params → protections → prepare → signers → assemble → submit → confirm → receipts"
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  intentId: { type: string }
                  status: { type: string, enum: [confirmed, awaiting_signatures, denied] }
                  txHash: { type: string }
                  chain: { type: string }
                  operation: { type: string }
                  summary: { type: string }
                  weight: { type: integer }
                  quorum: { type: integer }
                  signers: { type: array, items: { $ref: "#/components/schemas/SignerOutcome" } }
                  submit: { type: object }
                  receiptHead: { type: string }
                  mptIssuanceId: { type: string, description: "present after mpt_create" }
                  findings: { type: array, items: { $ref: "#/components/schemas/Finding" } }
        "500": { description: "refused by a guard, a signer, or the ledger", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /estate:
    get: { summary: "Balances per wallet with canonical flags", security: [ { session: [] } ], responses: { "200": { description: "estate" } } }
  /wallets/{id}/reconcile:
    get:
      summary: Control and history reconciliation against the live ledger
      security: [ { session: [] } ]
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses: { "200": { description: "clean, checked[], drift[]" } }
  /ledger/verify:
    get: { summary: "Verify the control-plane receipt chain", security: [ { session: [] } ], responses: { "200": { description: "verified, receipts, head, signerPublicKey" } } }
  /ledger/recent:
    get:
      summary: Recent receipts
      security: [ { session: [] } ]
      parameters: [ { name: limit, in: query, schema: { type: integer, default: 50 } } ]
      responses: { "200": { description: "receipts" } }
  /admin/reload:
    post: { summary: "Re-read state.json from disk", security: [ { session: [] } ], responses: { "200": { description: "reloaded" } } }
  /snapshot:
    post: { summary: "Everything the extension popup needs", security: [ { session: [] } ], responses: { "200": { description: "snapshot" } } }
  /recovery/status:
    get: { summary: "Recovery tier and proof per wallet", security: [ { session: [] } ], responses: { "200": { description: "status" } } }
  /pair/create:
    post: { summary: "Create a device pairing code (approver, initiator or viewer)", security: [ { session: [] } ], responses: { "200": { description: "code and challenge" } } }
  /pair/qr/{code}:
    get: { summary: "QR for a pairing code", parameters: [ { name: code, in: path, required: true, schema: { type: string } } ], responses: { "200": { description: "svg" } } }
  /pair/claim:
    post: { summary: "Claim a pairing code (unauthenticated by design)", responses: { "200": { description: "device registered" } } }
  /devices:
    get: { summary: "Paired devices", security: [ { session: [] } ], responses: { "200": { description: "devices" } } }
  /devices/{id}/revoke:
    post: { summary: "Revoke a device", security: [ { session: [] } ], parameters: [ { name: id, in: path, required: true, schema: { type: string } } ], responses: { "200": { description: "revoked" } } }
  /keys:
    get:
      summary: "(uny-signer) keys this signer holds, with scope"
      security: [ { signerToken: [] } ]
      responses: { "200": { description: "keys" } }
  /sign:
    post:
      summary: "(uny-signer) verify everything, rebuild, sign"
      security: [ { signerToken: [] } ]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SignRequest" } } } }
      responses:
        "200": { description: "signature", content: { application/json: { schema: { $ref: "#/components/schemas/SignResponse" } } } }
        "403": { description: "refused", content: { application/json: { schema: { $ref: "#/components/schemas/SignerRefusal" } } } }
  /receipts:
    get:
      summary: "(uny-signer) this signer's receipt chain"
      security: [ { signerToken: [] } ]
      parameters: [ { name: limit, in: query, schema: { type: integer, default: 20 } } ]
      responses: { "200": { description: "receipts" } }
