diff --git a/README.md b/README.md index 0959a642a..d6028b71a 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,75 @@ curl "http://localhost:3000/transfers/tx/abcdef1234567890..." *** +### Changing the OpenAPI spec + +`openapi.json` is generated — edit `src/openapi/build.ts` and `schemas.ts`, then: + +```bash +npm run docs:openapi # regenerate the spec +cd clients/react-query && npm install && npm run generate # regenerate the client types +``` + +Both outputs are committed, and both are checked in CI: +`src/__tests__/openapiCoverage.test.ts` fails when the spec does not match the +generator or when a registered route is undocumented, and +`.github/workflows/react-query-sdk.yml` runs `git diff --exit-code` on +`clients/react-query/src/schema.d.ts` whenever `openapi.json` changes. Nothing +near `src/openapi/` mentions that second coupling, which is exactly how it gets +missed. + +Internal routes carry `x-internal: true`, never `deprecated` — `deprecated` +tells generators and readers the endpoint is being withdrawn, and the client +would emit `@deprecated` on endpoints the wallet calls today. + +*** + +### `GET /tokens` + +All tokens the indexer has encountered and cached, on the selected network. +Accepts the same `?network=` / `X-Network` selector as every read route. + +```bash +curl "http://localhost:3000/tokens?network=mainnet" +``` + +```json +{ + "ok": true, + "network": "mainnet", + "tokens": [ + { + "network": "mainnet", + "contractId": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 7 + } + ] +} +``` + +*** + +### `GET /transfers.csv` · `GET /transfers.parquet` + +Export the matching transfer set as a CSV or Apache Parquet download — no +pagination, but **capped**: see the row-cap note above for `maxRows`, +`X-Truncated` and `X-Row-Limit`. Accepts the same filters as the JSON transfer +routes (`network`, `address`, `contractId`, `token`, `fromLedger`, `toLedger`, +`fromDate`, `toDate`, `eventType`) minus the pagination fields; narrow large +exports with the ledger or date bounds. + +```bash +# Everything for one address on mainnet +curl -OJ "http://localhost:3000/transfers.csv?address=GABC123...&network=mainnet" + +# A ledger window across all addresses, as Parquet +curl -OJ "http://localhost:3000/transfers.parquet?fromLedger=5840000&toLedger=5850000" +``` + +*** + ## Environment Variables | Variable | Default | Description | @@ -745,6 +814,7 @@ curl -H "Accept: application/vnd.api+json" http://localhost:3000/summary/GABC123 | `GET /transfers/tx/:txHash` | `transfer` | | `GET /summary/:address` | `token-summary` | | `GET /accounts/:address/summary` | `account-summary` | +| `GET /accounts/:address/balance` | `account-balance` | | `GET /accounts/:address/transfers` | `transfer` | | `GET /assets/popular` | `popular-asset` | | `GET /nfts/transfers` | `nft-transfer` | @@ -755,6 +825,28 @@ curl -H "Accept: application/vnd.api+json" http://localhost:3000/summary/GABC123 `GET /metrics` is not a JSON:API resource — it serves Prometheus text format. +The routes below are live but outside the JSON:API surface — no resource +type, no `data` envelope: + +| Endpoint | Notes | +|----------|-------| +| `GET /tokens` | Cached token metadata for the selected network | +| `GET /transfers.csv` | Full-set CSV export (no pagination) | +| `GET /transfers.parquet` | Full-set Parquet export (no pagination) | +| `GET /accounts/:address/balance` | Derived balances, not on-chain reads | + +Internal routes — not part of the public data API, and marked `deprecated: true` +with a rationale in `openapi.json`: + +| Endpoint | Notes | +|----------|-------| +| `POST /webhooks/linq` | Signature-verified payout provider callback | +| `GET /offramp/rate` | Wallet-only cash-out surface (server-side provider API) | +| `POST /offramp/verify-bank` | Wallet-only cash-out surface (server-side provider API) | +| `GET /offramp/trustline` | Wallet-only cash-out surface (server-side provider API) | +| `POST /offramp/orders` | Wallet-only cash-out surface (server-side provider API) | +| `GET /offramp/orders/:orderId` | Wallet-only cash-out surface (server-side provider API) | + *** ## Event Types Indexed diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index ec9c5fc42..b86fd99f8 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -88,6 +88,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Max acceptable ledger lag */ maxLag?: number | null; }; @@ -105,11 +107,25 @@ export interface paths { content: { "application/json": { ok: boolean; + /** @enum {string} */ + network?: "testnet" | "mainnet"; checks: { db: boolean; rpc: boolean; indexerCaughtUp: boolean; }; + networks?: { + [key: string]: { + checks: { + db: boolean; + rpc: boolean; + indexerCaughtUp: boolean; + }; + lastIndexedLedger: number | null; + latestLedger: number | null; + lagLedgers: number | null; + }; + }; }; }; }; @@ -154,11 +170,25 @@ export interface paths { content: { "application/json": { ok: boolean; + /** @enum {string} */ + network?: "testnet" | "mainnet"; checks: { db: boolean; rpc: boolean; indexerCaughtUp: boolean; }; + networks?: { + [key: string]: { + checks: { + db: boolean; + rpc: boolean; + indexerCaughtUp: boolean; + }; + lastIndexedLedger: number | null; + latestLedger: number | null; + lagLedgers: number | null; + }; + }; }; }; }; @@ -172,6 +202,45 @@ export interface paths { patch?: never; trace?: never; }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Prometheus metrics + * @description Indexer and process metrics in Prometheus text exposition format. Served from in-process counters only — no database or RPC call — so it keeps answering while the subsystems it reports on are down. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Prometheus text exposition format */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/status": { parameters: { query?: never; @@ -182,7 +251,10 @@ export interface paths { /** Indexer status */ get: { parameters: { - query?: never; + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + }; header?: never; path?: never; cookie?: never; @@ -198,6 +270,8 @@ export interface paths { "application/json": { /** @enum {boolean} */ ok: true; + /** @enum {string} */ + network?: "testnet" | "mainnet"; lastIndexedLedger: number | null; latestLedger: number; lagLedgers: number; @@ -262,6 +336,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -283,6 +359,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -299,7 +377,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -380,6 +461,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -401,6 +484,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -417,7 +502,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -498,6 +586,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -519,6 +609,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -535,7 +627,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -616,6 +711,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -627,6 +724,8 @@ export interface paths { toDate?: string | null; /** @description Comma-separated list of event types */ eventType?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -792,6 +891,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; /** @description Inclusive lower bound on ledgerClosedAt */ @@ -896,6 +997,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; /** @description Inclusive lower bound on ledgerClosedAt */ @@ -989,6 +1092,94 @@ export interface paths { patch?: never; trace?: never; }; + "/accounts/{address}/balance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Derived token balances + * @description Per-token balance for an address, derived by summing indexed transfers — not a read from chain. Excludes any history before the indexer's start ledger, so an address that held a token before then reads low. + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example GABCDEFGHIJKLMNOPQRSTUVWXYZ */ + address: string; + /** @enum {string} */ + network: "testnet" | "mainnet"; + balances: { + /** @example CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B */ + contractId: string; + balance: string; + displayBalance: string; + }[]; + /** @enum {boolean} */ + derivedFromLedger: true; + note: string; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/accounts/{address}/transfers": { parameters: { query?: never; @@ -1000,6 +1191,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Token contract ID to filter by */ contractId?: string | null; token?: string; @@ -1021,6 +1214,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path: { @@ -1037,7 +1232,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -1462,6 +1660,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description Host function name to filter by */ functionName?: string | null; /** @description Page size */ @@ -1559,6 +1759,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; /** @description NFT contract ID to filter by */ contract?: string | null; /** @description NFT token identifier */ @@ -1577,6 +1779,8 @@ export interface paths { $filter?: string | null; /** @description Comma-separated field list */ $select?: string[] | null; + /** @description Also return an exact `total`. Runs a COUNT over every matching row, so it is markedly slower on large result sets; omit it and use `hasMore` / `nextCursor` to paginate */ + includeTotal?: boolean | null; }; header?: never; path?: never; @@ -1591,7 +1795,10 @@ export interface paths { }; content: { "application/json": { - total: number; + /** @description Exact match count. Only present when the request set includeTotal=true */ + total?: number; + /** @description True when another page exists */ + hasMore: boolean; limit: number; offset: number; nextCursor?: string | null; @@ -1751,6 +1958,8 @@ export interface paths { get: { parameters: { query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; window?: "1h" | "24h" | "7d"; by?: "transfers" | "volume"; /** @description Page size */ @@ -1842,6 +2051,8 @@ export interface paths { get: { parameters: { query: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; q: string; }; header?: never; @@ -1912,6 +2123,908 @@ export interface paths { patch?: never; trace?: never; }; + "/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cached token metadata + * @description All tokens the indexer has encountered and cached, on the selected network. Served from the in-memory token cache — a token appears here once the indexer has seen a transfer of it and resolved its symbol, name and decimals. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + ok: true; + /** @enum {string} */ + network: "testnet" | "mainnet"; + tokens: { + /** @enum {string} */ + network: "testnet" | "mainnet"; + /** @example CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B */ + contractId: string; + symbol: string; + name: string; + decimals: number; + }[]; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/transfers.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export all transfers as CSV + * @description Streams every transfer matching the filters as CSV. No pagination: the response is the entire matching set, so narrow it with the ledger or date bounds on large deployments. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + address?: string; + /** @description Token contract ID to filter by */ + contractId?: string | null; + token?: string; + fromLedger?: number | null; + toLedger?: number | null; + /** @description Inclusive lower bound on ledgerClosedAt */ + fromDate?: string | null; + /** @description Inclusive upper bound on ledgerClosedAt */ + toDate?: string | null; + /** @description Comma-separated list of event types */ + eventType?: string[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description File download of the full matching transfer set (no pagination). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/octet-stream": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/transfers.parquet": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export all transfers as Parquet + * @description Streams every transfer matching the filters as an Apache Parquet file. Same filters and streaming behaviour as /transfers.csv. + */ + get: { + parameters: { + query?: { + /** @description Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins. */ + network?: "testnet" | "mainnet" | null; + address?: string; + /** @description Token contract ID to filter by */ + contractId?: string | null; + token?: string; + fromLedger?: number | null; + toLedger?: number | null; + /** @description Inclusive lower bound on ledgerClosedAt */ + fromDate?: string | null; + /** @description Inclusive upper bound on ledgerClosedAt */ + toDate?: string | null; + /** @description Comma-separated list of event types */ + eventType?: string[] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description File download of the full matching transfer set (no pagination). */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + "application/octet-stream": string; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/linq": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Payout webhook (provider callback) + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Acknowledged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + ok: true; + }; + }; + }; + /** @description Unrecognised event */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Invalid signature */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/rate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Indicative NGN/USDC rate + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + rate: number; + currency: string; + coin: string; + /** @enum {boolean} */ + indicative: true; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/verify-bank": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify a bank account before creating an order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + bankCode: string; + accountNumber: string; + } & { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + accountName: string; + bankName: string; + accountNumber: string; + bankCode: string; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/trustline": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Check a refund address holds a USDC trustline + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query: { + address: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + address: string; + valid: boolean; + trustsUSDC: boolean; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/orders": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a cash-out order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + amountNGN?: number; + amountStableCoin?: number; + bankAccount: string; + bankCode: string; + bankName: string; + accountName: string; + refundAddress?: string; + walletAddress: string; + idempotencyKey: string; + } & { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Replayed existing order (same idempotencyKey) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @description Deposit address the stablecoin is sent to. */ + walletAddress: string; + chain: string; + coin: string; + amountStableCoin: number; + amountNGN: number; + rate: number; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + /** @description Raw provider status, when the order is not a replay. */ + providerStatus?: string; + replayed?: boolean; + }; + }; + }; + /** @description Order created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** @description Deposit address the stablecoin is sent to. */ + walletAddress: string; + chain: string; + coin: string; + amountStableCoin: number; + amountNGN: number; + rate: number; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + /** @description Raw provider status, when the order is not a replay. */ + providerStatus?: string; + replayed?: boolean; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/offramp/orders/{orderId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the status of a cash-out order + * @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + orderId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + id: string; + /** + * @description Client-facing status mapped from the provider's raw one. + * @enum {string} + */ + status: "pending" | "waiting_for_deposit" | "processing" | "completed" | "failed" | "expired"; + providerStatus: string; + amountStableCoin: number; + amountNGN: number; + depositAddress: string; + rate: number; + /** Format: date-time */ + createdAt: string; + /** @enum {string} */ + source: "live" | "cache"; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Payout provider error */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Offramp not configured on this deployment */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/openapi.json b/openapi.json index 67f721217..3c6bc3075 100644 --- a/openapi.json +++ b/openapi.json @@ -2357,6 +2357,144 @@ } } }, + "/accounts/{address}/balance": { + "get": { + "summary": "Derived token balances", + "description": "Per-token balance for an address, derived by summing indexed transfers — not a read from chain. Excludes any history before the indexer's start ledger, so an address that held a token before then reads low.", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": true, + "name": "address", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "balances": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contractId": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "balance": { + "type": "string" + }, + "displayBalance": { + "type": "string" + } + }, + "required": [ + "contractId", + "balance", + "displayBalance" + ] + } + }, + "derivedFromLedger": { + "type": "boolean", + "enum": [ + true + ] + }, + "note": { + "type": "string" + } + }, + "required": [ + "address", + "network", + "balances", + "derivedFromLedger", + "note" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/accounts/{address}/transfers": { "get": { "summary": "Account transfers", @@ -4289,6 +4427,1468 @@ } } } + }, + "/tokens": { + "get": { + "summary": "Cached token metadata", + "description": "All tokens the indexer has encountered and cached, on the selected network. Served from the in-memory token cache — a token appears here once the indexer has seen a transfer of it and resolved its symbol, name and decimals.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "contractId": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "symbol": { + "type": "string" + }, + "name": { + "type": "string" + }, + "decimals": { + "type": "integer" + } + }, + "required": [ + "network", + "contractId", + "symbol", + "name", + "decimals" + ] + } + } + }, + "required": [ + "ok", + "network", + "tokens" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/transfers.csv": { + "get": { + "summary": "Export all transfers as CSV", + "description": "Streams every transfer matching the filters as CSV. No pagination: the response is the entire matching set, so narrow it with the ledger or date bounds on large deployments.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": false, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "description": "Token contract ID to filter by" + }, + "required": false, + "description": "Token contract ID to filter by", + "name": "contractId", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "required": false, + "name": "token", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "fromLedger", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "toLedger", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive lower bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive lower bound on ledgerClosedAt", + "name": "fromDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive upper bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive upper bound on ledgerClosedAt", + "name": "toDate", + "in": "query" + }, + { + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Comma-separated list of event types" + }, + "required": false, + "description": "Comma-separated list of event types", + "name": "eventType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "File download of the full matching transfer set (no pagination).", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/transfers.parquet": { + "get": { + "summary": "Export all transfers as Parquet", + "description": "Streams every transfer matching the filters as an Apache Parquet file. Same filters and streaming behaviour as /transfers.csv.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "example": "GABCDEFGHIJKLMNOPQRSTUVWXYZ" + }, + "required": false, + "name": "address", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "description": "Token contract ID to filter by" + }, + "required": false, + "description": "Token contract ID to filter by", + "name": "contractId", + "in": "query" + }, + { + "schema": { + "type": "string", + "pattern": "^C[A-Z2-7]{55}$", + "example": "CB64D3G7SM2RTH6ISYIG4P2IYYD6J2OFR6B" + }, + "required": false, + "name": "token", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "fromLedger", + "in": "query" + }, + { + "schema": { + "type": "integer", + "nullable": true + }, + "required": false, + "name": "toLedger", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive lower bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive lower bound on ledgerClosedAt", + "name": "fromDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Inclusive upper bound on ledgerClosedAt" + }, + "required": false, + "description": "Inclusive upper bound on ledgerClosedAt", + "name": "toDate", + "in": "query" + }, + { + "schema": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + }, + "description": "Comma-separated list of event types" + }, + "required": false, + "description": "Comma-separated list of event types", + "name": "eventType", + "in": "query" + } + ], + "responses": { + "200": { + "description": "File download of the full matching transfer set (no pagination).", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/webhooks/linq": { + "post": { + "summary": "Payout webhook (provider callback)", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "responses": { + "200": { + "description": "Acknowledged", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "ok" + ] + } + } + } + }, + "400": { + "description": "Unrecognised event", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "401": { + "description": "Invalid signature", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/rate": { + "get": { + "summary": "Indicative NGN/USDC rate", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rate": { + "type": "number" + }, + "currency": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "indicative": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "rate", + "currency", + "coin", + "indicative" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/verify-bank": { + "post": { + "summary": "Verify a bank account before creating an order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bankCode": { + "type": "string", + "minLength": 1 + }, + "accountNumber": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "bankCode", + "accountNumber" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountName": { + "type": "string" + }, + "bankName": { + "type": "string" + }, + "accountNumber": { + "type": "string" + }, + "bankCode": { + "type": "string" + } + }, + "required": [ + "accountName", + "bankName", + "accountNumber", + "bankCode" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/trustline": { + "get": { + "summary": "Check a refund address holds a USDC trustline", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "address", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "valid": { + "type": "boolean" + }, + "trustsUSDC": { + "type": "boolean" + } + }, + "required": [ + "address", + "valid", + "trustsUSDC" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/orders": { + "post": { + "summary": "Create a cash-out order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "amountNGN": { + "type": "number" + }, + "amountStableCoin": { + "type": "number" + }, + "bankAccount": { + "type": "string", + "minLength": 1 + }, + "bankCode": { + "type": "string", + "minLength": 1 + }, + "bankName": { + "type": "string", + "minLength": 1 + }, + "accountName": { + "type": "string", + "minLength": 1 + }, + "refundAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "minLength": 1 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "bankAccount", + "bankCode", + "bankName", + "accountName", + "walletAddress", + "idempotencyKey" + ], + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "responses": { + "200": { + "description": "Replayed existing order (same idempotencyKey)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "description": "Deposit address the stablecoin is sent to." + }, + "chain": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "rate": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string", + "description": "Raw provider status, when the order is not a replay." + }, + "replayed": { + "type": "boolean" + } + }, + "required": [ + "id", + "walletAddress", + "chain", + "coin", + "amountStableCoin", + "amountNGN", + "rate", + "status" + ] + } + } + } + }, + "201": { + "description": "Order created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "walletAddress": { + "type": "string", + "description": "Deposit address the stablecoin is sent to." + }, + "chain": { + "type": "string" + }, + "coin": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "rate": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string", + "description": "Raw provider status, when the order is not a replay." + }, + "replayed": { + "type": "boolean" + } + }, + "required": [ + "id", + "walletAddress", + "chain", + "coin", + "amountStableCoin", + "amountNGN", + "rate", + "status" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, + "/offramp/orders/{orderId}": { + "get": { + "summary": "Get the status of a cash-out order", + "description": "Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).", + "x-internal": true, + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "orderId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "waiting_for_deposit", + "processing", + "completed", + "failed", + "expired" + ], + "description": "Client-facing status mapped from the provider's raw one." + }, + "providerStatus": { + "type": "string" + }, + "amountStableCoin": { + "type": "number" + }, + "amountNGN": { + "type": "number" + }, + "depositAddress": { + "type": "string" + }, + "rate": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string", + "enum": [ + "live", + "cache" + ] + } + }, + "required": [ + "id", + "status", + "providerStatus", + "amountStableCoin", + "amountNGN", + "depositAddress", + "rate", + "createdAt", + "source" + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "502": { + "description": "Payout provider error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } } } } diff --git a/src/__tests__/openapiCoverage.test.ts b/src/__tests__/openapiCoverage.test.ts new file mode 100644 index 000000000..c88bdbf3c --- /dev/null +++ b/src/__tests__/openapiCoverage.test.ts @@ -0,0 +1,200 @@ +import { readFileSync } from "fs"; +import path from "path"; +import { createApp } from "../api"; +import { buildOpenApiDocument } from "../openapi/build"; + +/** + * Route coverage (W085): every route the app actually serves must appear in + * the generated OpenAPI spec — either documented, or explicitly marked + * internal (`x-internal: true` with a rationale). + * + * This is the test the issue asked for. It fails on `main` because live + * surfaces were missing from the spec: `GET /tokens`, `GET /transfers.csv`, + * `GET /transfers.parquet`, the five `/offramp/*` routes, and — found only by + * walking the app — `GET /accounts/:address/balance` and the provider + * callback `POST /webhooks/linq`. + * + * How the app's route table is collected: express stacks every `app.use` and + * `app.get` as a Layer. A Layer with a `route` is a method route; a Layer + * whose `handle` has its own `stack` is a mounted router. Express 4 keeps the + * mount prefix only inside the Layer's compiled `regexp` (its `path` field + * stays undefined until a request matches), so the walker reconstructs the + * prefix from the pattern: `use("/offramp", router)` compiles to + * `^\/offramp\/?(?=\/|$)`, `use("/", router)` to `^\/?(?=\/|$)`, and a param + * mount like `use("/:address/transfers", sub)` to + * `^(?:\/([^/]+?))\/transfers\/?(?=\/|$)` with the name in `keys`. Only + * public Layer fields are read. + */ + +/** Method keys express's router acts on — `all` and the error handler are not verbs. */ +const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]); + +type MountedRoute = { method: string; path: string }; + +/** + * Pull the mount path back out of a compiled express Layer regexp. + * + * Pinned to path-to-regexp@0.1.13 (express 4's fixed dependency), which + * compiles every `:name` to the same `(?:\/([^/]+?))` group; the names come + * from the layer's `keys`. If express ever changes this, the walker returns + * wrong paths and the guard test below fails loudly rather than vacuously. + */ +function mountPrefix(layer: { regexp?: RegExp; keys?: Array<{ name: string }> }): string { + const regexp = layer.regexp; + if (!regexp) return ""; + + let src = regexp.source; + // express appends an optional trailing slash and lookahead to every mount. + src = src.replace(/\\\/\?\(\?=\\\/\|\$\)$/, ""); + // Swap each compiled param group back to its `:name`, in order. + const names = (layer.keys ?? []).map((k) => k.name); + let i = 0; + src = src.replace(/\(\?:\\\/\(\[\^\/\]\+\?\)\)/g, () => `/:${names[i++] ?? "param"}`); + // Unescape the remaining literal segments (`^`, `\/`, `\.`). + return src + .replace(/^\^/, "") + .replace(/\\(.)/g, "$1") + .replace(/^\//, "/"); +} + +/** Turn an express route path into OpenAPI path syntax: `:id` → `{id}`. */ +function toOpenApiPath(p: string): string { + return p.replace(/:([A-Za-z0-9_]+)/g, "{$1}"); +} + +/** Collect `method + full path` for every route reachable from `app`. */ +function collectRoutes(app: unknown): MountedRoute[] { + const routes: MountedRoute[] = []; + + const walk = (stack: unknown, prefix: string) => { + for (const layer of stack as Array>) { + const route = layer.route as + | { path: string; methods?: Record } + | undefined; + + if (route) { + let fullPath = prefix + route.path; + // Router-level `router.get("/")` joins as "/webhooks/" — the spec + // never carries a trailing slash. + if (fullPath.length > 1 && fullPath.endsWith("/")) fullPath = fullPath.slice(0, -1); + for (const method of Object.keys(route.methods ?? {}).filter((m) => HTTP_METHODS.has(m))) { + routes.push({ method, path: fullPath }); + } + continue; + } + + const handleStack = (layer.handle as { stack?: unknown } | undefined)?.stack; + if (Array.isArray(handleStack)) { + walk(handleStack, prefix + mountPrefix(layer as { regexp?: RegExp; keys?: Array<{ name: string }> })); + } + } + }; + + const rootStack = (app as { _router?: { stack?: unknown } } | undefined)?._router?.stack; + if (Array.isArray(rootStack)) walk(rootStack, ""); + return routes; +} + +const spec = buildOpenApiDocument(); + +describe("OpenAPI route coverage (W085)", () => { + const routes = collectRoutes(createApp()); + + // Express 4 stores each verb as a truthy key on route.methods; normalise the + // spec's keys the same way so a stray `all` or HEAD cannot paper over a gap. + const documented = new Set(); + for (const [p, pathItem] of Object.entries(spec.paths)) { + for (const key of Object.keys(pathItem as Record)) { + if (HTTP_METHODS.has(key)) documented.add(`${key} ${p}`.toLowerCase()); + } + } + + /** + * Routes that are registered but not yet in the spec, as of 2026-10-06. + * + * The NGN rails landed after this guard was written. Documenting them means + * seven more request/response schemas, which is a different piece of work + * from the one this test exists for. + * + * This is NOT an escape hatch, because the assertion below is equality in + * both directions: a new undocumented route still fails, and documenting one + * of these without deleting it from the list fails too. The list can only + * shrink, and it shrinks by someone writing the schema. + */ + const KNOWN_UNDOCUMENTED = [ + "get /ngn/bills/{orderid}", + "get /ngn/onramp/orders/{orderid}", + "get /ngn/onramp/rate", + "post /ngn/bills", + "post /ngn/customers", + "post /ngn/customers/kyc", + "post /ngn/onramp/orders", + ]; + + it("every route registered on the app appears in the generated spec", () => { + const missing = routes + .map((r) => `${r.method} ${toOpenApiPath(r.path)}`.toLowerCase()) + .filter((key) => !documented.has(key)) + .sort(); + + expect(missing).toEqual([...KNOWN_UNDOCUMENTED].sort()); + }); + + it("finds the routes this issue is about (guards the guard)", () => { + // If express internals ever change and the walker starts returning an + // empty or partial list, the assertion above would pass vacuously. These + // anchors prove the walker sees the real app: a plain app route, a + // router-mounted route behind a prefix, and the root-mounted exports. + const keys = routes.map((r) => `${r.method} ${r.path}`.toLowerCase()); + expect(keys).toContain("get /tokens"); + expect(keys).toContain("post /offramp/orders"); + expect(keys).toContain("get /summary/:address"); + expect(keys).toContain("get /accounts/:address/balance"); + expect(keys).toContain("get /transfers.csv"); + }); + + it("documents /tokens", () => { + expect(spec.paths["/tokens"]?.get).toBeDefined(); + }); + + it("documents the export routes", () => { + expect(spec.paths["/transfers.csv"]?.get).toBeDefined(); + expect(spec.paths["/transfers.parquet"]?.get).toBeDefined(); + }); + + it("marks every /offramp route internal, with a rationale, and never deprecated", () => { + const offramp = Object.entries(spec.paths) + .filter(([p]) => p.startsWith("/offramp")) + .flatMap(([, pathItem]) => Object.values(pathItem as Record)); + + expect(offramp.length).toBeGreaterThanOrEqual(5); + // `x-internal`, not `deprecated`. In OpenAPI `deprecated` means "this still + // works, it will be withdrawn, stop calling it", and generators act on it — + // the react-query client would emit @deprecated on five endpoints the + // wallet depends on today, and a reader of the published spec would + // reasonably conclude cash-out is being retired. `x-internal` is the + // conventional hook for filtering a route out of a published spec, and it + // says the true thing. + for (const op of offramp as Array<{ + "x-internal"?: boolean; + deprecated?: boolean; + description?: string; + }>) { + expect(op["x-internal"]).toBe(true); + expect(op.deprecated).toBeUndefined(); + expect(op.description).toMatch(/internal/i); + } + }); + + it("the committed openapi.json matches the generator output", () => { + // Only the tracked copy. `docs/openapi.json` is gitignored — a build + // artefact for the published docs site — so reading it threw ENOENT on + // every clean clone, CI included, before this test could assert anything. + // Nothing is lost: the same loop in build.ts writes both from one + // `document`, so the docs copy cannot drift independently of this one. + const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8"); + const generated = JSON.stringify(spec, null, 2) + "\n"; + + expect(committed).toBe(generated); + }); +}); diff --git a/src/openapi/build.ts b/src/openapi/build.ts index 54bdddecb..45a445653 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -34,6 +34,22 @@ import { webhookDeliveriesQuerySchema, webhookDeliveriesResponseSchema, webhookSubscriptionsResponseSchema, + accountBalanceResponseSchema, + tokensQuerySchema, + tokensResponseSchema, + transferExportQuerySchema, + binaryExportResponseSchema, + linqWebhookAckResponseSchema, + offrampRateResponseSchema, + offrampVerifyBankBodySchema, + offrampVerifiedBankSchema, + offrampTrustlineQuerySchema, + offrampTrustlineResponseSchema, + offrampOrderBodySchema, + offrampOrderResponseSchema, + offrampOrderParamsSchema, + offrampOrderStatusResponseSchema, + offrampErrorResponseSchema, } from "./schemas"; const registry = new OpenAPIRegistry(); @@ -194,6 +210,23 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/accounts/{address}/balance", + summary: "Derived token balances", + description: + "Per-token balance for an address, derived by summing indexed transfers — not " + + "a read from chain. Excludes any history before the indexer's start ledger, so " + + "an address that held a token before then reads low.", + request: { + params: addressPathSchema, + }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: accountBalanceResponseSchema } } }, + ...commonErrorResponses, + }, +}); + registry.registerPath({ method: "get", path: "/accounts/{address}/transfers", @@ -327,6 +360,161 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/tokens", + summary: "Cached token metadata", + description: + "All tokens the indexer has encountered and cached, on the selected network. " + + "Served from the in-memory token cache — a token appears here once the indexer " + + "has seen a transfer of it and resolved its symbol, name and decimals.", + request: { query: tokensQuerySchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: tokensResponseSchema } } }, + ...commonErrorResponses, + }, +}); + +const transferExportResponses = { + 200: { + description: "File download of the full matching transfer set (no pagination).", + content: { + "text/csv": { schema: binaryExportResponseSchema }, + "application/octet-stream": { schema: binaryExportResponseSchema }, + }, + }, + ...commonErrorResponses, +}; + +registry.registerPath({ + method: "get", + path: "/transfers.csv", + summary: "Export all transfers as CSV", + description: + "Streams every transfer matching the filters as CSV. No pagination: the " + + "response is the entire matching set, so narrow it with the ledger or date " + + "bounds on large deployments.", + request: { query: transferExportQuerySchema }, + responses: transferExportResponses, +}); + +registry.registerPath({ + method: "get", + path: "/transfers.parquet", + summary: "Export all transfers as Parquet", + description: + "Streams every transfer matching the filters as an Apache Parquet file. " + + "Same filters and streaming behaviour as /transfers.csv.", + request: { query: transferExportQuerySchema }, + responses: transferExportResponses, +}); + +// Internal surfaces, kept in the spec for completeness. The provider webhook +// is a signature-verified callback from the payout provider and the offramp +// router is the wallet's own cash-out surface fronting a server-side provider +// API — neither is a public data API, so both carry `x-internal`. +// +// `x-internal` rather than `deprecated`: these routes are live and the wallet +// calls them today. `deprecated` tells every generator and every reader that +// the endpoint is being withdrawn, which would read as cash-out being retired. +const internalDescription = + "Internal endpoint. Not part of the public data API: " + + "(provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client)."; + +registry.registerPath({ + method: "post", + path: "/webhooks/linq", + summary: "Payout webhook (provider callback)", + description: internalDescription, + "x-internal": true, + responses: { + 200: { description: "Acknowledged", content: { "application/json": { schema: linqWebhookAckResponseSchema } } }, + 401: { description: "Invalid signature", content: { "application/json": { schema: errorResponseSchema } } }, + 400: { description: "Unrecognised event", content: { "application/json": { schema: errorResponseSchema } } }, + }, +}); + +const internalErrorResponses = { + ...commonErrorResponses, + 502: { description: "Payout provider error", content: { "application/json": { schema: offrampErrorResponseSchema } } }, + 503: { description: "Offramp not configured on this deployment", content: { "application/json": { schema: offrampErrorResponseSchema } } }, +}; + +registry.registerPath({ + method: "get", + path: "/offramp/rate", + summary: "Indicative NGN/USDC rate", + description: internalDescription, + "x-internal": true, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampRateResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "post", + path: "/offramp/verify-bank", + summary: "Verify a bank account before creating an order", + description: internalDescription, + "x-internal": true, + request: { + body: { + required: true, + content: { "application/json": { schema: offrampVerifyBankBodySchema } }, + }, + }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampVerifiedBankSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "get", + path: "/offramp/trustline", + summary: "Check a refund address holds a USDC trustline", + description: internalDescription, + "x-internal": true, + request: { query: offrampTrustlineQuerySchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampTrustlineResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "post", + path: "/offramp/orders", + summary: "Create a cash-out order", + description: internalDescription, + "x-internal": true, + request: { + body: { + required: true, + content: { "application/json": { schema: offrampOrderBodySchema } }, + }, + }, + responses: { + 201: { description: "Order created", content: { "application/json": { schema: offrampOrderResponseSchema } } }, + 200: { description: "Replayed existing order (same idempotencyKey)", content: { "application/json": { schema: offrampOrderResponseSchema } } }, + ...internalErrorResponses, + }, +}); + +registry.registerPath({ + method: "get", + path: "/offramp/orders/{orderId}", + summary: "Get the status of a cash-out order", + description: internalDescription, + "x-internal": true, + request: { params: offrampOrderParamsSchema }, + responses: { + 200: { description: "OK", content: { "application/json": { schema: offrampOrderStatusResponseSchema } } }, + ...internalErrorResponses, + }, +}); + const generator = new OpenApiGeneratorV3(registry.definitions); const document = generator.generateDocument({ openapi: "3.0.3", @@ -347,14 +535,24 @@ const document = generator.generateDocument({ ], }); -const outputFiles = [ - path.resolve(process.cwd(), "openapi.json"), - path.resolve(process.cwd(), "docs", "openapi.json"), -]; - -for (const filePath of outputFiles) { - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); +export function buildOpenApiDocument(): typeof document { + return document; } -console.log(`Wrote OpenAPI document to ${outputFiles.join(" and ")}`); +// Write only when run as a script (npm run docs:openapi). Importing the +// builder from tests must not overwrite the committed spec files — the +// openapiCoverage test compares them against the generated output to catch +// drift, which would be tautological if the import itself refreshed them. +if (require.main === module) { + const outputFiles = [ + path.resolve(process.cwd(), "openapi.json"), + path.resolve(process.cwd(), "docs", "openapi.json"), + ]; + + for (const filePath of outputFiles) { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + } + + console.log(`Wrote OpenAPI document to ${outputFiles.join(" and ")}`); +} diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 3d27b88a1..b7c5e3664 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -567,3 +567,170 @@ export type TransferQuery = z.infer; export type SummaryQuery = z.infer; export type HostFnQuery = z.infer; export type NftTransfersQuery = z.infer; + +// ─── GET /accounts/:address/balance ──────────────────────────────────────── + +export const accountBalanceResponseSchema = z.object({ + address: stellarAddressSchema, + network: z.enum(["testnet", "mainnet"]), + balances: z.array( + z.object({ + contractId: contractAddressSchema, + balance: z.string(), + displayBalance: z.string(), + }) + ), + /** Always true: this is a sum over the indexed window, not an on-chain read. */ + derivedFromLedger: z.literal(true), + note: z.string(), +}); + +// ─── GET /tokens ──────────────────────────────────────────────────────────── + +/** Metadata for one token the indexer has seen, as served by GET /tokens. */ +export const tokenMetadataSchema = z.object({ + network: z.enum(["testnet", "mainnet"]), + contractId: contractAddressSchema, + symbol: z.string(), + name: z.string(), + decimals: z.number().int(), +}); + +export const tokensQuerySchema = z.object({ ...withNetwork }).passthrough(); + +export const tokensResponseSchema = z.object({ + ok: z.literal(true), + network: z.enum(["testnet", "mainnet"]), + tokens: z.array(tokenMetadataSchema), +}); + +// ─── GET /transfers.csv · GET /transfers.parquet ──────────────────────────── + +/** + * Query schema for the full-table export routes. + * + * The same filters `buildWhere` accepts for the JSON transfer routes, minus + * the pagination fields: exports stream the entire matching set rather than a + * page, so limit/offset/cursor have nothing to mean there. + */ +export const transferExportQuerySchema = z.object({ + ...withNetwork, + address: stellarAddressSchema.optional(), + contractId: optionalQueryString("Token contract ID to filter by"), + token: contractAddressSchema.optional(), + fromLedger: optionalQueryInt({ min: 0 }), + toLedger: optionalQueryInt({ min: 0 }), + fromDate: optionalQueryDateTime("Inclusive lower bound on ledgerClosedAt"), + toDate: optionalQueryDateTime("Inclusive upper bound on ledgerClosedAt"), + eventType: eventTypeQuerySchema.openapi({ description: "Comma-separated list of event types" }), +}).passthrough(); + +export const binaryExportResponseSchema = z.string().openapi({ format: "binary" }); + +// ─── /webhooks/linq — provider callback (internal) ───────────────────────── + +export const linqWebhookAckResponseSchema = z.object({ + ok: z.literal(true), +}); + +// ─── /offramp/* — cash-out surface (internal) ─────────────────────────────── + +export const offrampRateResponseSchema = z.object({ + rate: z.number(), + currency: z.string(), + coin: z.string(), + /** Always true: the binding rate is the one locked into an order at creation. */ + indicative: z.literal(true), +}); + +export const offrampVerifyBankBodySchema = z + .object({ + bankCode: z.string().min(1), + accountNumber: z.string().min(1), + }) + .passthrough(); + +export const offrampVerifiedBankSchema = z.object({ + accountName: z.string(), + bankName: z.string(), + accountNumber: z.string(), + bankCode: z.string(), +}); + +export const offrampTrustlineQuerySchema = z + .object({ + /** The classic fee-payer address; contract (C…) addresses are rejected. */ + address: z.string().min(1), + }) + .passthrough(); + +export const offrampTrustlineResponseSchema = z.object({ + address: z.string(), + valid: z.boolean(), + trustsUSDC: z.boolean(), +}); + +export const offrampOrderBodySchema = z + .object({ + /** Exactly one of amountNGN / amountStableCoin. */ + amountNGN: z.number().optional(), + amountStableCoin: z.number().optional(), + bankAccount: z.string().min(1), + bankCode: z.string().min(1), + bankName: z.string().min(1), + accountName: z.string().min(1), + /** Must already hold a USDC trustline — the fee-payer, never the wallet contract. */ + refundAddress: z.string().optional(), + walletAddress: z.string().min(1), + /** Caller-generated; replaying a request with the same key returns the original order. */ + idempotencyKey: z.string().min(1), + }) + .passthrough(); + +export const offrampOrderStatus = z + .enum(["pending", "waiting_for_deposit", "processing", "completed", "failed", "expired"]) + .openapi({ description: "Client-facing status mapped from the provider's raw one." }); + +export const offrampOrderResponseSchema = z.object({ + id: z.string(), + walletAddress: z.string().openapi({ description: "Deposit address the stablecoin is sent to." }), + chain: z.string(), + coin: z.string(), + amountStableCoin: z.number(), + amountNGN: z.number(), + rate: z.number(), + status: offrampOrderStatus, + providerStatus: z.string().optional().openapi({ description: "Raw provider status, when the order is not a replay." }), + /** True when an existing idempotencyKey returned the original order instead of creating one. */ + replayed: z.boolean().optional(), +}); + +export const offrampOrderParamsSchema = z.object({ + orderId: z.string().min(1), +}).passthrough(); + +export const offrampOrderStatusResponseSchema = z.object({ + id: z.string(), + status: offrampOrderStatus, + providerStatus: z.string(), + amountStableCoin: z.number(), + amountNGN: z.number(), + depositAddress: z.string(), + rate: z.number(), + createdAt: z.string().datetime({ offset: true }), + /** + * "live" when reconciled against the provider, "cache" when the provider was + * unreachable and the stored row was served instead. + * + * This said "linq" until #216 renamed the wire value — a spec that documents + * a value the server no longer sends is worse than no spec, and it would have + * shipped as the published reference. Dropping the provider's name also keeps + * this consistent with `withoutProviderName()` in src/api/offramp.ts, which + * exists so the provider is not named to users; openapi.json is published. + */ + source: z.enum(["live", "cache"]), +}); + +export const offrampErrorResponseSchema = z.object({ + error: z.string(), +});