{
  "openapi": "3.1.0",
  "info": {
    "title": "Vurto Swap — Public API",
    "version": "1.0.0",
    "description": "Public API for AI agents and integrators to quote, build, sign, and track EVM token swaps across 9 chains (Ethereum, Optimism, BNB Chain, Gnosis, Polygon, Base, Arbitrum One, Avalanche, Unichain) via Vurto's meta-aggregator (Velora, KyberSwap, 1inch, OpenOcean, CoW Protocol, and more, ranked by net value after gas and fee). No API key required for read and quote endpoints. Building a swap, submitting a signed order, requesting an ETH-flow refund, and reading/writing execution history require a machine credential (see the bearerAuth security scheme) or an authenticated browser session. Agents must sign and send built transactions onchain themselves — this API never holds custody of funds or private keys, and rejects any request payload that looks like one.",
    "contact": { "name": "Vurto", "url": "https://vurto.cc" }
  },
  "servers": [
    { "url": "https://swap.vurto.cc/v1", "description": "Production" },
    { "url": "https://swap-dev.vurto.cc/v1", "description": "Development" }
  ],
  "security": [{}, { "bearerAuth": [] }],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "vswap_<id>_<secret>",
        "description": "Machine credential, issued via POST /api/credentials after proving wallet ownership with an EIP-191 signature (browser-only flow, not part of this spec). Scopes: 'read' (quote, tokens, balances, list executions) and 'build' (swap, orders, executions write, ethflow refund). Each credential carries a budget of 240 cost units per rolling 60s window; requests beyond that return 429 budget_exhausted."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": { "type": "string", "description": "Stable machine-readable error code. Match on this, not on `message`." },
              "message": { "type": "string", "description": "Human-readable, English, safe to show a user." },
              "details": { "description": "Present on some errors (e.g. ambiguous_token, slippage_exceeded, route_changed). Shape depends on `code`." }
            }
          }
        }
      },
      "Chain": {
        "type": "object",
        "properties": {
          "chainId": { "type": "integer", "example": 42161 },
          "name": { "type": "string", "example": "Arbitrum One" },
          "shortName": { "type": "string", "example": "Arbitrum" },
          "nativeSymbol": { "type": "string", "example": "ETH" },
          "explorer": { "type": "string", "format": "uri" },
          "blockSeconds": { "type": "number", "description": "Average block time in seconds." },
          "icon": { "type": "string", "description": "Path under swap.vurto.cc, e.g. /icons/networks/arbitrum.png" },
          "wrappedNative": { "type": "string", "description": "Wrapped native token address (WETH, WBNB, etc.)." }
        }
      },
      "TokenInfo": {
        "type": "object",
        "properties": {
          "address": { "type": "string", "description": "Checksummed contract address, or 0xEeee…EEeE for the chain's native asset." },
          "symbol": { "type": "string" },
          "name": { "type": "string" },
          "decimals": { "type": "integer" },
          "verified": { "type": "boolean", "description": "True for tokens in the curated seed list; false for on-chain-resolved tokens the caller pasted an address for." }
        }
      },
      "RouteLeg": {
        "type": "object",
        "properties": {
          "exchange": { "type": "string" },
          "pool": { "type": "string" },
          "tokenIn": { "type": "string" },
          "tokenOut": { "type": "string" },
          "shareBps": { "type": "integer", "description": "Share of the input routed through this leg, in basis points, for split routes." }
        }
      },
      "QuoteNotice": {
        "type": "object",
        "properties": {
          "code": { "type": "string", "description": "Stable code; translate client-side, don't display raw." },
          "vars": { "type": "object", "additionalProperties": true }
        }
      },
      "NormalizedQuote": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Opaque quote id. Pass as `quoteId` to POST /swap or POST /orders." },
          "provider": { "type": "string", "enum": ["velora", "kyberswap", "1inch", "openocean", "cowswap"], "description": "Full provider list varies by chain; see GET /chains and provider health." },
          "providerLabel": { "type": "string" },
          "chainId": { "type": "integer" },
          "tokenIn": { "type": "string" },
          "tokenOut": { "type": "string" },
          "amountIn": { "type": "string", "description": "Raw integer string, in the input token's smallest unit." },
          "amountOut": { "type": "string", "description": "Raw integer string, the provider's expected output." },
          "minimumAmountOut": { "type": "string", "description": "Raw integer string, the worst acceptable output after slippage. This is what the on-chain call enforces." },
          "amountOutUsd": { "type": ["number", "null"] },
          "gasUsd": { "type": ["number", "null"] },
          "netValueUsd": { "type": ["number", "null"], "description": "Output value minus estimated gas minus platform fee. This is the ranking key, not amountOut." },
          "priceImpact": { "type": ["number", "null"] },
          "platformFeeBps": { "type": "integer" },
          "platformFeeAmount": { "type": "string" },
          "confidence": { "type": "integer", "description": "0-100, lower when the provider's own health has degraded recently." },
          "latencyMs": { "type": "integer" },
          "quotedAt": { "type": "integer", "description": "Unix ms." },
          "expiresAt": { "type": "integer", "description": "Unix ms. The provider's own quote TTL, informational — POST /swap always re-quotes fresh before building." },
          "quoteBlock": { "type": ["integer", "null"] },
          "mevProtection": { "type": "string", "enum": ["full", "partial", "public", "unknown"] },
          "supportsReceiver": { "type": "boolean", "description": "Whether this provider can deliver the output to a `receiver` different from the wallet that signs. false for openocean and 0x, which only support sender == receiver." },
          "receiver": { "type": "string", "description": "Present only when the request set a `receiver` different from the signing wallet." },
          "approvalTarget": { "type": ["string", "null"], "description": "Spender address the input token needs an allowance for, when applicable." },
          "route": { "type": "array", "items": { "$ref": "#/components/schemas/RouteLeg" } },
          "badges": { "type": "array", "items": { "type": "string" }, "description": "Highlight codes, e.g. best_price, lowest_gas." },
          "warnings": { "type": "array", "items": { "$ref": "#/components/schemas/QuoteNotice" } },
          "executionKind": { "type": "string", "enum": ["transaction", "order", "onchain_order"], "description": "Only present on quotes returned from POST /quote. 'transaction': send `steps[].tx`. 'order': sign EIP-712 off-chain, no transaction. 'onchain_order': CoW ETH-flow, a real transaction that only registers the order." }
        }
      },
      "PlanStep": {
        "type": "object",
        "description": "One action the caller must take, in order, to execute the swap. `type: approve|transaction` carry ready-to-send calldata in `tx`. `type: signature` carries EIP-712 `typedData` to sign off-chain, plus `submit` describing how to hand the signature back.",
        "properties": {
          "index": { "type": "integer" },
          "type": { "type": "string", "enum": ["approve", "transaction", "signature"] },
          "humanSummary": { "type": "string" },
          "token": { "type": "string" },
          "spender": { "type": "string" },
          "amount": { "type": "string" },
          "resetFirst": { "type": "boolean", "description": "True when this approve step must zero the allowance before the real approve step runs (some tokens, e.g. USDT, reject a non-zero-to-non-zero approve)." },
          "tx": {
            "type": "object",
            "properties": {
              "to": { "type": "string" },
              "data": { "type": "string" },
              "value": { "type": "string" }
            }
          },
          "gasEstimate": { "type": "string" },
          "scheme": { "type": "string", "enum": ["eip712"] },
          "typedData": {
            "type": "object",
            "properties": {
              "domain": { "type": "object" },
              "types": { "type": "object" },
              "primaryType": { "type": "string" },
              "message": { "type": "object" }
            }
          },
          "submit": {
            "type": "object",
            "description": "Where and how to POST the signature produced from `typedData`.",
            "properties": {
              "url": { "type": "string", "format": "uri" },
              "method": { "type": "string", "enum": ["POST"] },
              "bodyTemplate": { "type": "object", "additionalProperties": true, "description": "Known fields already filled in (chainId as a number, quoteId as a string); fill in `signature` and POST as-is." }
            }
          }
        }
      },
      "MultiQuoteLeg": {
        "type": "object",
        "description": "One real, quoted tokenIn->tokenOut slice of an N:N basket — the backend's waterfall allocation decided this slice's size, then quoted it through the same provider fan-out POST /quote uses. This is what POST /multi-quote/build will execute, not a rough preview.",
        "properties": {
          "legIndex": { "type": "integer", "description": "Position in the basket's flattened leg list, 0-based." },
          "inputLegIndex": { "type": "integer", "description": "Index into the request's inputLegs[] this leg draws from." },
          "outputLegIndex": { "type": "integer", "description": "Index into the request's outputLegs[] this leg funds." },
          "tokenIn": { "type": "string" },
          "tokenOut": { "type": "string" },
          "tokenInDecimals": { "type": "integer" },
          "tokenOutDecimals": { "type": "integer" },
          "amountIn": { "type": "string", "description": "Raw integer string: this leg's share of the input token after allocation, not the full inputLegs[] amount." },
          "receiver": { "type": "string" },
          "quote": { "$ref": "#/components/schemas/NormalizedQuote" },
          "netAmountOutEstimate": { "type": "string", "description": "Display estimate only: quote.amountOut minus VurtoSwapRouter's 0.03% multi-leg fee. Not the enforced floor — quote.minimumAmountOut is what the on-chain call requires." }
        }
      },
      "MultiBuildLeg": {
        "type": "object",
        "properties": {
          "legIndex": { "type": "integer" },
          "provider": { "type": "string", "enum": ["velora", "kyberswap", "1inch", "openocean"], "description": "CoW never appears here: an off-chain signature or a separately-settled ETH-flow order can't be one leg of a single atomic transaction." },
          "tokenIn": { "type": "string" },
          "tokenOut": { "type": "string" },
          "amountIn": { "type": "string" },
          "minAmountOut": { "type": "string", "description": "Raw minimum this leg's on-chain call enforces, before VurtoSwapRouter's own fee is deducted from what actually reaches the wallet." },
          "receiver": { "type": "string" },
          "netAmountOutEstimate": { "type": "string" }
        }
      },
      "SwapPlan": {
        "type": "object",
        "properties": {
          "buildId": { "type": "string", "format": "uuid", "description": "Identity of this specific build. Use in POST /swap/{buildId}/refresh." },
          "buildHash": { "type": "string", "description": "sha256 canonical hash of chainId + wallet + destination + calldata + value. Recompute and compare before signing if you don't trust the transport." },
          "wallet": { "type": "string" },
          "chainId": { "type": "integer" },
          "validUntil": { "type": "integer", "description": "Unix ms. The build expires 10 minutes after creation; call refresh after this." },
          "quote": { "$ref": "#/components/schemas/NormalizedQuote" },
          "simulation": {
            "type": "object",
            "properties": {
              "status": { "type": "string", "enum": ["passed", "approval_required", "incomplete"] },
              "gasEstimate": { "type": "string" },
              "reason": { "type": "string" },
              "outputCheck": { "type": "string", "enum": ["passed", "skipped"] },
              "outputDelta": { "type": "string" }
            }
          },
          "steps": { "type": "array", "items": { "$ref": "#/components/schemas/PlanStep" } },
          "slippageBps": { "type": "integer", "description": "Only present on refresh responses: how many bps the output dropped versus the original build. 0 when the price didn't get worse." },
          "refresh": {
            "type": "object",
            "properties": {
              "url": { "type": "string", "format": "uri" },
              "method": { "type": "string", "enum": ["POST"] },
              "body": {
                "type": "object",
                "properties": { "slippageBps": { "type": ["integer", "null"] } }
              }
            }
          }
        }
      }
    }
  },
  "paths": {
    "/health": {
      "get": {
        "operationId": "health",
        "summary": "Liveness check",
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" }, "time": { "type": "string", "format": "date-time" } } } } }
          }
        }
      }
    },
    "/chains": {
      "get": {
        "operationId": "listChains",
        "summary": "Supported chains",
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Chain" } } } } } }
          }
        }
      }
    },
    "/tokens": {
      "get": {
        "operationId": "listTokens",
        "summary": "Curated token list for a chain",
        "description": "Cacheable for 15 minutes. Use this to resolve a symbol to an address client-side, or just pass the symbol straight to POST /quote and let the server resolve it (recommended — it also catches ambiguous symbols like USDC vs USDC.e).",
        "security": [],
        "parameters": [
          { "name": "chainId", "in": "query", "required": true, "schema": { "type": "integer" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "chainId": { "type": "integer" }, "items": { "type": "array", "items": { "$ref": "#/components/schemas/TokenInfo" } } } } } }
          },
          "400": { "description": "unsupported_chain", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/token": {
      "get": {
        "operationId": "lookupToken",
        "summary": "Resolve an arbitrary token address on-chain",
        "description": "Reads symbol(), name(), decimals() live via RPC for a contract address not in the curated list, and caches the result 24h. Rate-limited per IP (30/min) — this triggers real RPC calls.",
        "security": [],
        "parameters": [
          { "name": "chainId", "in": "query", "required": true, "schema": { "type": "integer" } },
          { "name": "address", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "item": { "$ref": "#/components/schemas/TokenInfo" }, "cached": { "type": "boolean" } } } } }
          },
          "400": { "description": "unsupported_chain | invalid_token_address", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "rate_limited", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/balances": {
      "get": {
        "operationId": "getBalances",
        "summary": "Wallet balances for every curated token on a chain, plus native",
        "description": "One multicall per chain, batched in slices so a bad token can't zero out the whole response. Only non-zero balances are included. Rate-limited per IP (40/min).",
        "security": [],
        "parameters": [
          { "name": "chainId", "in": "query", "required": true, "schema": { "type": "integer" } },
          { "name": "wallet", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": {
              "type": "object",
              "properties": {
                "chainId": { "type": "integer" },
                "balances": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Keyed by lowercase token address, or the literal 'native'. Raw integer strings." },
                "usd": { "type": "object", "additionalProperties": { "type": "number" }, "description": "Same keys as balances, best-effort — a missing key means price lookup failed, not that the balance is zero." }
              }
            } } }
          }
        }
      }
    },
    "/quote": {
      "post": {
        "operationId": "quote",
        "summary": "Get ranked routes for a swap, without building anything",
        "description": "tokenIn/tokenOut accept a contract address or a symbol (case-insensitive). A symbol matching more than one token on that chain returns 409 ambiguous_token with every candidate — never guesses. Quotes are indicative for anonymous callers (no session/credential): they can't be built, only shown. With a credential or session, quotes carry a real handoff and `quotes[].id` can be passed to POST /swap.",
        "security": [{}, { "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["chainId", "tokenIn", "tokenOut"],
            "properties": {
              "chainId": { "type": "integer" },
              "tokenIn": { "type": "string", "description": "Address or symbol." },
              "tokenOut": { "type": "string", "description": "Address or symbol." },
              "amount": { "type": "string", "description": "Human decimal amount, e.g. \"1.5\". Use this OR amountRaw, not both." },
              "amountRaw": { "type": "string", "description": "Raw integer string in the input token's smallest unit." },
              "slippageBps": { "type": "integer", "description": "Defaults to a sane per-pair value server-side when omitted." },
              "receiver": { "type": "string", "description": "Optional address to deliver the output to, if different from the signing wallet. Only providers with `supportsReceiver: true` are returned when this is set — see NormalizedQuote." }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": {
              "type": "object",
              "properties": {
                "requestId": { "type": "string", "format": "uuid" },
                "resolved": {
                  "type": "object",
                  "properties": {
                    "tokenIn": { "$ref": "#/components/schemas/TokenInfo" },
                    "tokenOut": { "$ref": "#/components/schemas/TokenInfo" },
                    "amountRaw": { "type": "string" }
                  }
                },
                "quotes": { "type": "array", "items": { "$ref": "#/components/schemas/NormalizedQuote" }, "description": "Ranked best first by netValueUsd." },
                "bestQuoteId": { "type": ["string", "null"] },
                "failures": { "type": "array", "items": { "type": "object", "properties": { "provider": { "type": "string" }, "code": { "type": "string" } } } },
                "indicative": { "type": "boolean" },
                "refreshedAt": { "type": "integer" }
              }
            } } }
          },
          "400": { "description": "invalid_chain_id | token_required | invalid_amount | amount_required | same_token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "unknown_symbol", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "ambiguous_token — details.candidates has every match", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "502": { "description": "no_routes — no provider returned a safe route", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/swap": {
      "post": {
        "operationId": "swap",
        "summary": "Build a signable/sendable plan for a swap",
        "description": "Requires a machine credential or session with 'build' scope. Two modes: pass `quoteId` from a prior POST /quote to build exactly that route; omit it and pass the intent fields (chainId, tokenIn, tokenOut, amount|amountRaw) to quote and build the best route in one round trip. Always re-quotes fresh before building — if the market moved, `quote.amountOut` reflects the fresh price, not the one from POST /quote.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "properties": {
              "quoteId": { "type": "string" },
              "chainId": { "type": "integer" },
              "tokenIn": { "type": "string" },
              "tokenOut": { "type": "string" },
              "amount": { "type": "string" },
              "amountRaw": { "type": "string" },
              "slippageBps": { "type": "integer" },
              "maxSlippageBps": { "type": "integer", "description": "Optional ceiling, 0-10000, for every future POST /swap/{buildId}/refresh of this build. Once set it never loosens: a refresh asking for a laxer value is clamped back down to this." },
              "receiver": { "type": "string", "description": "Optional address to deliver the output to, if different from the signing wallet. Ignored when `quoteId` is set — the receiver from that quote is used. Only honored by providers with `supportsReceiver: true`." }
            }
          } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SwapPlan" } } } },
          "400": { "description": "invalid_slippage_bps | invalid_chain_id | token_required | same_token | amount_required | invalid_amount", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "missing or invalid credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "scope_required — credential lacks the 'build' scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "quote_expired | route_changed | insufficient_balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "budget_exhausted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "502": { "description": "no_routes", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/multi-quote": {
      "post": {
        "operationId": "multiQuote",
        "summary": "Quote an N:N basket: N input tokens funding M output tokens in one atomic transaction",
        "description": "Not decomposable into separate POST /quote calls — the backend's waterfall allocation (which input finances which output) is the thing being computed, then each resulting slice is quoted through the same provider fan-out POST /quote uses. inputLegs[] is what you sell (tokenIn + amount|amountRaw, each). outputLegs[] is what you want back as outputPercent, an integer percent of the TOTAL basket value (not a fixed amount) — every outputLegs[].outputPercent in the request must sum to exactly 100. Up to 10 combined input+output legs; the waterfall never produces more than inputLegs.length + outputLegs.length - 1 real on-chain legs. CoW is never a candidate leg (an EIP-712 signature or a separately-settled ETH-flow order can't be one leg of one atomic transaction). Indicative for anonymous callers, same rule as POST /quote — legs[].quote.id isn't buildable without a credential, and POST /multi-quote/build doesn't take a quoteId anyway (see below).",
        "security": [{}, { "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["chainId", "inputLegs", "outputLegs"],
            "properties": {
              "chainId": { "type": "integer", "description": "The multi-swap router must be deployed on this chain or the request fails with 503 multi_swap_not_supported_on_chain." },
              "inputLegs": {
                "type": "array", "minItems": 1,
                "items": { "type": "object", "required": ["tokenIn"], "properties": {
                  "tokenIn": { "type": "string", "description": "Address or symbol." },
                  "amount": { "type": "string", "description": "Human decimal amount. Use this OR amountRaw, not both." },
                  "amountRaw": { "type": "string", "description": "Raw integer string in tokenIn's smallest unit." }
                } }
              },
              "outputLegs": {
                "type": "array", "minItems": 1,
                "items": { "type": "object", "required": ["tokenOut", "outputPercent"], "properties": {
                  "tokenOut": { "type": "string", "description": "Address or symbol." },
                  "outputPercent": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Integer percent of the TOTAL basket value this leg receives. Every outputLegs[].outputPercent must sum to exactly 100." },
                  "receiver": { "type": "string", "description": "Optional: deliver this leg's output to a different address than the signing wallet." }
                } }
              },
              "slippageBps": { "type": "integer", "minimum": 5, "maximum": 500, "description": "Applied to every leg. Default 50." }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": {
              "type": "object",
              "properties": {
                "requestId": { "type": "string", "format": "uuid" },
                "chainId": { "type": "integer" },
                "wallet": { "type": "string" },
                "pivot": { "type": "string", "description": "USDC address on this chain — the internal reference asset the waterfall allocation prices every input/output against. Not part of execution." },
                "legs": { "type": "array", "items": { "$ref": "#/components/schemas/MultiQuoteLeg" }, "description": "Every leg the allocation produced and successfully quoted — this is what POST /multi-quote/build executes." },
                "failures": { "type": "array", "items": { "type": "object", "properties": { "legIndex": { "type": "integer" }, "code": { "type": "string" } } }, "description": "A partial basket is possible and reported here, never silently dropped." },
                "indicative": { "type": "boolean" },
                "refreshedAt": { "type": "integer" }
              }
            } } }
          },
          "400": { "description": "invalid_chain_id | invalid_input_legs | invalid_output_legs | too_many_legs | invalid_output_percent | token_required | invalid_amount | amount_required | zero_value_input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "ambiguous_token — details.candidates has every match", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "502": { "description": "no_routes — no leg found a composable route; details.failures lists why | pivot_pricing_failed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "description": "multi_swap_not_supported_on_chain | multi_swap_pivot_unavailable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/multi-quote/build": {
      "post": {
        "operationId": "multiQuoteBuild",
        "summary": "Build one atomic transaction executing every leg of an N:N basket",
        "description": "Requires a machine credential or session with 'build' scope. Always re-quotes the whole basket fresh from chainId/inputLegs/outputLegs — there is no quoteId handoff for N:N (unlike POST /swap), so pass the same fields used for POST /multi-quote, or skip straight to this endpoint. steps[] contains one approve step per DISTINCT input token that still needs allowance, followed by ONE transaction step executing every leg atomically. The approve target is the VurtoSwapRouter address (steps[].spender / the response's router), NOT each leg's underlying provider — this router pulls every input token itself inside one contract call, so approving providers individually the way POST /swap's steps do would approve the wrong address. If simulation.status is approval_required, send the approve step(s) first and call this endpoint again for the executable transaction. POST /executions and GET /executions/{id} do not support this endpoint's transaction step — both key off a single quoteId, and this build has none. Confirm success via the transaction receipt directly.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["chainId", "inputLegs", "outputLegs"],
            "properties": {
              "chainId": { "type": "integer" },
              "inputLegs": {
                "type": "array", "minItems": 1,
                "items": { "type": "object", "required": ["tokenIn"], "properties": {
                  "tokenIn": { "type": "string" },
                  "amount": { "type": "string" },
                  "amountRaw": { "type": "string" }
                } }
              },
              "outputLegs": {
                "type": "array", "minItems": 1,
                "items": { "type": "object", "required": ["tokenOut", "outputPercent"], "properties": {
                  "tokenOut": { "type": "string" },
                  "outputPercent": { "type": "integer", "minimum": 1, "maximum": 100 },
                  "receiver": { "type": "string" }
                } }
              },
              "slippageBps": { "type": "integer", "minimum": 5, "maximum": 500, "description": "Applied to every leg. Default 50." }
            }
          } } }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": { "application/json": { "schema": {
              "type": "object",
              "properties": {
                "buildId": { "type": "string", "format": "uuid" },
                "buildHash": { "type": "string" },
                "wallet": { "type": "string" },
                "chainId": { "type": "integer" },
                "router": { "type": "string", "description": "VurtoSwapRouter address for this chain. Every approve step's spender." },
                "validUntil": { "type": "integer", "description": "Unix ms, 10 minutes out. No refresh endpoint for N:N — rebuild by calling this endpoint again with the same intent." },
                "legs": { "type": "array", "items": { "$ref": "#/components/schemas/MultiBuildLeg" } },
                "simulation": {
                  "type": "object",
                  "properties": {
                    "status": { "type": "string", "enum": ["passed", "approval_required", "incomplete"] },
                    "gasEstimate": { "type": "string" },
                    "reason": { "type": "string" },
                    "perLeg": { "description": "Present when simulation ran per-leg detail." }
                  }
                },
                "steps": { "type": "array", "items": { "$ref": "#/components/schemas/PlanStep" } },
                "refreshedAt": { "type": "integer" }
              }
            } } }
          },
          "400": { "description": "invalid_chain_id | invalid_input_legs | invalid_output_legs | too_many_legs | invalid_output_percent | token_required | invalid_amount | amount_required | zero_value_input | no_legs | invalid_receiver", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "missing or invalid credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "scope_required — credential lacks the 'build' scope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "ambiguous_token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "budget_exhausted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "502": { "description": "no_routes | leg_not_composable | pivot_pricing_failed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "description": "multi_swap_not_supported_on_chain | multi_swap_pivot_unavailable", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/swap/{buildId}/refresh": {
      "post": {
        "operationId": "refreshSwap",
        "summary": "Rebuild an existing plan with fresh pricing, without losing slippage protection",
        "description": "Use the `refresh` object from a prior SwapPlan response (or GET/derive `buildId` from `buildId` field) instead of calling POST /swap again with the same quoteId — this endpoint enforces two things POST /swap alone doesn't: the slippage ceiling never loosens across refreshes (`effectiveSlippageCap`), and if the underlying route's final destination address changed (not just repriced — a genuinely different router), it fails closed with 409 route_changed instead of silently swapping you into a different route. When the original provider disappears from the fan-out entirely, the 409 response embeds the current best route ready to use in `error.details.alternative`, so you don't need a third round trip.",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "buildId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
        ],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": {
            "type": "object",
            "properties": {
              "slippageBps": { "type": "integer", "description": "0-10000. Clamped down to the original build's maxSlippageBps when one was set; never loosened." }
            }
          } } }
        },
        "responses": {
          "200": { "description": "OK, a new SwapPlan with a new buildId", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SwapPlan" } } } },
          "400": { "description": "invalid_slippage_bps", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "missing or invalid credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "404": { "description": "build_not_found — wrong wallet, or the 10-minute build window expired", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "route_changed (details.alternative may carry a ready plan) | slippage_exceeded (details has originalAmountOut, freshAmountOut, slippageBps, maxSlippageBps) | quote_expired — the underlying quote handoff (2 min) expired; call POST /swap fresh instead", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/orders": {
      "post": {
        "operationId": "submitOrder",
        "summary": "Submit a signed CoW order",
        "description": "Only for plans where `steps[].type === 'signature'`. Sign `steps[].typedData` with the wallet's private key (EIP-712), then POST the fields from `steps[].submit.bodyTemplate` with `signature` filled in. The server re-derives the order from the buildId's stored quote server-side — the body you send is proof of signature, not the source of truth for what gets submitted, so a compromised transport can't smuggle a different order past your signature.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["chainId", "quoteId", "signature"],
            "properties": {
              "chainId": { "type": "integer" },
              "quoteId": { "type": "string" },
              "signature": { "type": "string" },
              "order": { "type": "object", "description": "Optional: echo back the signed message for the server to double-check it matches what it built. Mismatches reject with 409 instead of submitting." }
            }
          } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "uid": { "type": "string", "description": "CoW order UID, 56 bytes hex. Cite this in support, there's no transaction hash." }, "buildHash": { "type": "string" } } } } } },
          "400": { "description": "invalid_cow_order | quote_id_required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "build_expired | build_chain_mismatch | order does not match build", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/executions": {
      "post": {
        "operationId": "recordExecution",
        "summary": "Record a sent transaction into swap history",
        "description": "For `type: transaction`/`approve` plans only — CoW orders record themselves via POST /orders. Confirms the transaction actually exists on-chain (via rpc.vurto.cc) and its calldata hash matches a build the caller owns before writing anything.",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["txHash", "quoteId"],
            "properties": {
              "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$" },
              "quoteId": { "type": "string" }
            }
          } } }
        },
        "responses": {
          "201": { "description": "Created", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "status": { "type": "string", "enum": ["submitted", "confirmed", "failed"] } } } } } },
          "400": { "description": "invalid_tx_hash | quote_id_required", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "409": { "description": "build_expired | tx_owned_by_another_wallet | tx_not_found_yet | transaction_build_mismatch | build_not_simulated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      },
      "get": {
        "operationId": "listExecutions",
        "summary": "List this wallet's swap history",
        "security": [{ "bearerAuth": [] }],
        "responses": {
          "200": { "description": "OK, newest first, capped at 50", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "type": "object" } } } } } } }
        }
      }
    },
    "/executions/{id}": {
      "get": {
        "operationId": "refreshExecutionStatus",
        "summary": "Re-check a recorded execution's on-chain status",
        "description": "Polls the receipt again, and for a CoW ETH-flow transaction also checks the orderbook — a confirmed ETH-flow transaction only means the order was registered, the solver executes it later (or never).",
        "security": [{ "bearerAuth": [] }],
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "status": { "type": "string", "enum": ["submitted", "confirmed", "failed"] } } } } } },
          "404": { "description": "swap_not_found — wrong wallet or unknown id", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/ethflow/refund": {
      "post": {
        "operationId": "refundEthFlow",
        "summary": "Build a refund transaction for an expired, unfulfilled CoW ETH-flow order",
        "security": [{ "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["chainId", "txHash"],
            "properties": {
              "chainId": { "type": "integer" },
              "txHash": { "type": "string", "pattern": "^0x[0-9a-fA-F]{64}$", "description": "The original ETH-flow transaction hash." }
            }
          } } }
        },
        "responses": {
          "200": { "description": "OK, a transaction ready to send", "content": { "application/json": { "schema": { "type": "object" } } } },
          "400": { "description": "invalid_tx_hash | not_ethflow_order", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "refund_not_yours", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/report": {
      "post": {
        "operationId": "submitReport",
        "summary": "Report a problem, with optional structured context",
        "description": "Anonymous calls are accepted by design — whoever is stuck before authenticating is exactly who most needs this. Rate-limited 5 per 5 minutes per IP. The request body goes through the same secret filter as every other endpoint: a message or context field that looks like a private key or seed phrase is rejected before anything is written.",
        "security": [{}, { "bearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": {
            "type": "object",
            "required": ["message"],
            "properties": {
              "message": { "type": "string", "minLength": 8, "maxLength": 2000 },
              "source": { "type": "string", "description": "Free text; use \"agent\" for anything not from the web UI." },
              "chainId": { "type": "integer" },
              "context": { "type": "object", "description": "Arbitrary diagnostic JSON, e.g. the last quote/build/error you saw. Truncated at 8000 chars, stored as-is, never interpreted." },
              "appVersion": { "type": "string" }
            }
          } } }
        },
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "string" }, "received": { "type": "boolean" } } } } } },
          "400": { "description": "report_too_short | secret_in_payload", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "429": { "description": "rate_limited", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    }
  }
}
