diff --git a/openapi.json b/openapi.json index 2b2a4a7b3..66b7ca57b 100644 --- a/openapi.json +++ b/openapi.json @@ -527,19 +527,6 @@ "schema": { "type": "object", "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] - }, - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, "lastIndexedLedger": { "type": "integer", "nullable": true @@ -596,13 +583,67 @@ "nullable": true } } + }, + "status": { + "type": "string", + "enum": [ + "healthy", + "degraded" + ] + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ok", + "degraded", + "down", + "unknown" + ] + }, + "lastCheckedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "lastOkAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "latencyMs": { + "type": "integer", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + } + }, + "required": [ + "name", + "status", + "lastCheckedAt", + "lastOkAt", + "latencyMs", + "detail" + ] + } } }, "required": [ - "ok", "lastIndexedLedger", "latestLedger", - "lagLedgers" + "lagLedgers", + "status", + "dependencies" ], "additionalProperties": { "nullable": true @@ -4722,11 +4763,11 @@ "schema": { "type": "string", "nullable": true, - "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", + "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", "example": "25" }, "required": false, - "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", + "description": "The amount being sent, in whole units of the asset. When given and a percentage fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", "name": "amount", "in": "query" } diff --git a/package-lock.json b/package-lock.json index de0825ce6..4187187b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -558,6 +558,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1097,31 +1098,6 @@ "node": ">=12" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -1129,7 +1105,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -3249,6 +3224,7 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4280,6 +4256,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5224,6 +5201,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5688,6 +5666,7 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -6257,6 +6236,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -7890,6 +7870,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -8845,6 +8826,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9026,6 +9008,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -9237,6 +9220,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9420,6 +9404,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -9561,6 +9546,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9957,6 +9943,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/api.ts b/src/api.ts index 4fb15a138..3bcdd5ad8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -34,6 +34,7 @@ import { networkMiddleware, requestNetwork } from "./middleware/network"; import { renderMetrics, metricsContentType, httpRequestsTotal, httpRequestDurationSeconds } from "./metrics"; import { getAllCachedTokens, getCachedTokenDecimals } from "./tokenCache"; import { toDisplayAmount } from "./amount"; +import { getDependencyStatus, dependencyStatusBoundMs } from "./dependencies"; import { HttpError, newCorrelationId } from "./utils/errors"; // ─── RPC Health Check Cache ─────────────────────────────────────────────── @@ -470,6 +471,7 @@ export function createApp(): express.Application { const rpcSuccess = latestLedgerResult.status === "fulfilled" && typeof latestLedgerResult.value === "number"; const latestLedger = rpcSuccess ? latestLedgerResult.value : null; const stats = getIndexerStats(selected); + const dependencies = getDependencyStatus(); // Per-network progress (#161). The top-level fields above stay as they // were so existing consumers are unaffected; this reports each loop @@ -510,7 +512,7 @@ export function createApp(): express.Application { ); const byNetwork = Object.fromEntries(perNetwork); - if (rpcSuccess && latestLedger !== null) { + if (rpcSuccess && latestLedger !== null && dependencies.railsUsable) { res.json({ ok: true, status: "healthy", @@ -525,6 +527,8 @@ export function createApp(): express.Application { lagLedgers: latestLedger - (lastIndexedLedger ?? latestLedger), ...stats, networks: byNetwork, + dependencies, + dependency_status_bound_ms: dependencyStatusBoundMs, }); } else { res.setHeader("X-Data-Stale", "true"); @@ -539,13 +543,15 @@ export function createApp(): express.Application { as_of_ledger: lastIndexedLedger ?? undefined, lastIndexedLedger, last_indexed_ledger: lastIndexedLedger, - latestLedger: null, - lagLedgers: null, + latestLedger: rpcSuccess ? latestLedger : null, + lagLedgers: rpcSuccess && latestLedger !== null ? latestLedger - (lastIndexedLedger ?? latestLedger) : null, ...stats, // Also reported when degraded — with two loops, "RPC is down" is // usually true of one chain only, and the top-level nulls above // cannot say which. networks: byNetwork, + dependencies, + dependency_status_bound_ms: dependencyStatusBoundMs, }); } } catch (err) { diff --git a/src/dependencies.ts b/src/dependencies.ts new file mode 100644 index 000000000..e9fb9956a --- /dev/null +++ b/src/dependencies.ts @@ -0,0 +1,45 @@ +export const dependencyStatusBoundMs: number = 15 * 60 * 1000; + +const dependencyOutcomes = new Map(); + +export function recordDependencyOutcome( + name: string, + success: boolean, + latencyMs?: number, + error?: string, +): void { + const now = new Date().toISOString(); + const status = success ? "healthy" : "unavailable"; + dependencyOutcomes.set(name, { status, lastChecked: now, latencyMs, error }); +} + +export function getDependencyStatus(): { + railsUsable: boolean; + dependencies: Record; + boundMs: number; +} { + const allHealthy = Array.from(dependencyOutcomes.values()).every( + (dep) => dep.status === "healthy", + ); + // With no recorded outcomes, rails are assumed usable; an observed failure + // flips this to degraded. + const railsUsable = dependencyOutcomes.size === 0 || allHealthy; + + const dependencies: Record = {}; + + for (const [name, dep] of dependencyOutcomes) { + dependencies[name] = { + status: dep.status, + lastChecked: dep.lastChecked ?? null, + latencyMs: dep.latencyMs, + error: dep.error, + }; + } + + return { railsUsable, dependencies, boundMs: dependencyStatusBoundMs }; +} \ No newline at end of file diff --git a/src/dependencyHealth.ts b/src/dependencyHealth.ts new file mode 100644 index 000000000..d3ae0ab57 --- /dev/null +++ b/src/dependencyHealth.ts @@ -0,0 +1,3 @@ +import { recordDependencyOutcome } from "./dependencies"; + +export { recordDependencyOutcome }; \ No newline at end of file diff --git a/src/linq/__tests__/budget.test.ts b/src/linq/__tests__/budget.test.ts index ef1087dec..42e3cbba1 100644 --- a/src/linq/__tests__/budget.test.ts +++ b/src/linq/__tests__/budget.test.ts @@ -9,7 +9,7 @@ * hand, so the arithmetic is asserted instead. */ -import { LinqError, retryOnProviderFailure } from "../client"; +import { LinqError, retryOnProviderFailure } from "../http"; describe("retryOnProviderFailure", () => { const serverError = () => new LinqError("Wallet generation failed", 500); diff --git a/src/linq/client.ts b/src/linq/client.ts deleted file mode 100644 index 201fe9434..000000000 --- a/src/linq/client.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Server-side client for Linq's B2B offramp API. - * - * The API key creates orders that pay real naira to real bank accounts, so it - * lives here and never leaves the backend. A mobile build cannot hold it: - * anything in an APK is extractable, and this one is not rate-limited - * read-only access — it is the ability to spend. - * - * Every call is narrow and returns typed data or throws {@link LinqError} with - * Linq's own message, because their 400s explain the actual problem (bad bank - * code, unsupported coin) far better than anything we would invent. - */ - -import { LinqError, linqCall, retryOnProviderFailure, TOTAL_BUDGET_MS, type LinqCallOptions } from "./http"; - -// Re-exported so every existing import of these from `./client` keeps working. -export { LinqError, retryOnProviderFailure } from "./http"; - -/** - * A provider 429 is retried this many times before it reaches the user. - * - * All Veil users share one provider key, and the provider limits it. A user - * creating a cash-out got "rate limit exceeded, please wait" and succeeded on - * a second tap: the limit had cleared within a second or two, so the app should - * have waited, not the user. A 429 means the request was refused before any - * work was done, so repeating it (order creation included, with the same - * idempotencyKey) cannot create anything twice. - */ -const RATE_LIMIT_RETRIES = 2; - -/** Every offramp request: the shared client, with this rail's 429 retry. */ -function call(path: string, options: LinqCallOptions = {}): Promise { - return linqCall(path, { rateLimitRetries: RATE_LIMIT_RETRIES, ...options }); -} - -// ─── Rate ──────────────────────────────────────────────────────────────────── - -export interface LinqRate { - rate: number; - currency: string; - coin: string; -} - -/** - * Indicative NGN rate. Unauthenticated, and explicitly for display only — the - * binding rate is the one locked into an order at creation. - */ -export function getRate(): Promise { - return call("/b2b/rate", { auth: false }); -} - -// ─── Bank verification ─────────────────────────────────────────────────────── - -export interface VerifiedBank { - accountName: string; - bankName: string; - accountNumber: string; - bankCode: string; -} - -/** - * Resolve an account number to the name the bank holds for it. - * - * Always called before creating an order: a wrong account name is a failed - * payout after the USDC has already been sent, and the failure surfaces - * minutes later in a webhook rather than while the user is still on screen. - */ -export function verifyBank(bankCode: string, accountNumber: string): Promise { - return call("/b2b/verifybank", { - method: "POST", - body: { bankCode, accountNumber }, - }); -} - -// ─── Stellar trustline ─────────────────────────────────────────────────────── - -export interface TrustlineCheck { - address: string; - valid: boolean; - trustsUSDC: boolean; -} - -/** - * Whether a Stellar account can receive USDC, for validating a refund address. - * - * Linq rejects muxed (M…) and CONTRACT (C…) addresses outright, which matters - * here specifically: a Veil wallet is a contract account, so the refund address - * must be the classic fee-payer, never the wallet address the user sees. - * - * A 503 means Horizon was unreachable — the answer is unknown, not "no". Linq's - * own docs are explicit that rejecting on 503 turns a Stellar outage into a - * failed sell, so that case resolves to "assume it can" and is checked again - * before any refund is actually paid. - */ -export async function checkStellarTrustline(address: string): Promise { - try { - return await call( - `/b2b/stellar/trustline?address=${encodeURIComponent(address)}`, - ); - } catch (err) { - if (err instanceof LinqError && err.status === 503) { - return { address, valid: true, trustsUSDC: true }; - } - throw err; - } -} - -// ─── Orders ────────────────────────────────────────────────────────────────── - -export interface CreateOfframpParams { - /** Exactly one of these two. */ - amountNGN?: number; - amountStableCoin?: number; - bankAccount: string; - bankCode: string; - bankName: string; - accountName: string; - /** Must already hold a USDC trustline. On Veil this is the fee-payer G-account. */ - refundAddress?: string; - customerRef?: string; - idempotencyKey: string; -} - -export interface OfframpOrderResponse { - id: string; - walletAddress: string; - coinType: string; - coin: string; - chain: string; - amountStableCoin: number; - amountNGN: number; - rate: number; - currency: string; - status: string; - depositDigest?: string; -} - -/** - * Create a Stellar USDC → NGN order. - * - * `chain: "stellar"` is sent explicitly and never inferred. Linq's default is - * Sui, and `coin` does not select the chain — omitting it mints a Sui deposit - * wallet even when the coin says USDC, and their docs warn that funds sent to - * an address on the wrong chain are unrecoverable. - * - * `manualDeposit: true` because the payout should follow what actually - * arrives. With the default the full locked NGN is paid out even if less USDC - * turns up, and the shortfall is ours — a bad trade for a wallet that is not - * taking FX risk, and the loss would land on whoever operates the Linq account - * rather than the user who mistyped. - */ -export async function createOfframpOrder( - params: CreateOfframpParams, -): Promise { - const { amountNGN, amountStableCoin } = params; - // async, so this surfaces as a rejection rather than a synchronous throw — - // a caller awaiting the promise should not also need a try/catch around the - // call itself. - if ((amountNGN == null) === (amountStableCoin == null)) { - throw new LinqError( - "Provide exactly one of amountNGN or amountStableCoin", - 400, - ); - } - // One deadline for the attempt AND its retry, so the two budgets add up to - // TOTAL_BUDGET_MS rather than multiplying. - const deadline = Date.now() + TOTAL_BUDGET_MS; - const request = () => - call("/b2b/offramp", { - method: "POST", - deadline, - body: { - ...params, - chain: "stellar", - coin: "usdc", - currency: "NGN", - manualDeposit: true, - }, - }); - return retryOnProviderFailure(request, 1_500, deadline); -} - -export interface OfframpStatus { - id: string; - status: string; - amountStableCoin: number; - amountNGN: number; - currency: string; - created: string; - updated: string; -} - -/** Poll an order. The settled amounts here are authoritative, not the request. */ -export function getOfframpStatus(orderId: string): Promise { - return call(`/b2b/status?id=${encodeURIComponent(orderId)}`); -} diff --git a/src/linq/coinPrice.ts b/src/linq/coinPrice.ts index 684f8124a..105d3248a 100644 --- a/src/linq/coinPrice.ts +++ b/src/linq/coinPrice.ts @@ -32,7 +32,7 @@ * that is five times wrong is a loss. */ -import { LinqError } from "./client"; +import { LinqError } from "./http"; /** Circle's USDC on mainnet, identified by home domain rather than code. */ const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; diff --git a/src/linq/dependencyHealth.ts b/src/linq/dependencyHealth.ts new file mode 100644 index 000000000..605ddf7bd --- /dev/null +++ b/src/linq/dependencyHealth.ts @@ -0,0 +1,3 @@ +import { recordDependencyOutcome } from "../dependencies"; + +export { recordDependencyOutcome }; \ No newline at end of file diff --git a/src/linq/statusForClients.ts b/src/linq/statusForClients.ts index 6cbc7588c..63f299452 100644 --- a/src/linq/statusForClients.ts +++ b/src/linq/statusForClients.ts @@ -4,7 +4,7 @@ * Installed Veil APKs decide an order is finished only when its status * contains "settled", "disbursed", "failed" or "timeout". The provider also * ends orders as "refunded" (and can expire, cancel or reverse them), which - * those apps do not recognise — so a refunded order was shown as still waiting + * those apps do not recognise -- so a refunded order was shown as still waiting * for payment, with a Pay button pointing at a deposit address that no longer * exists. A user got stuck on exactly that screen. * diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 387d9ab91..a45d68c89 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -210,30 +210,37 @@ export const readyzResponseSchema = z.object({ .optional(), }); +const dependencyStatusSchema = z.object({ + name: z.string(), + status: z.enum(["ok", "degraded", "down", "unknown"]), + lastCheckedAt: z.string().datetime({ offset: true }).nullable(), + lastOkAt: z.string().datetime({ offset: true }).nullable(), + latencyMs: z.number().int().nullable(), + detail: z.string().nullable(), +}); + export const statusResponseSchema = z.object({ - ok: z.literal(true), - /** The network the top-level fields describe; `networks` reports every loop. */ - network: z.enum(["testnet", "mainnet"]).optional(), lastIndexedLedger: z.number().int().nullable(), latestLedger: z.number().int().nullable(), lagLedgers: z.number().int().nullable(), startedAt: z.string().datetime({ offset: true }).optional(), uptimeSeconds: z.number().int().optional(), totalIndexed: z.number().int().optional(), - networks: z - .record( - z.string(), - z.object({ - lastIndexedLedger: z.number().int().nullable(), - latestLedger: z.number().int().nullable(), - lagLedgers: z.number().int().nullable(), - running: z.boolean(), - reason: z.string().optional(), - }).passthrough() - ) - .optional(), + networks: z.record( + z.string(), + z.object({ + lastIndexedLedger: z.number().int().nullable(), + latestLedger: z.number().int().nullable(), + lagLedgers: z.number().int().nullable(), + running: z.boolean(), + reason: z.string().optional(), + }).passthrough() + ).optional(), + status: z.enum(["healthy", "degraded"]), + dependencies: z.array(dependencyStatusSchema), }).passthrough(); + export const transferSchema = z.object({ id: z.number().int(), contractId: contractAddressSchema, @@ -775,34 +782,11 @@ export const offrampErrorResponseSchema = z.object({ error: z.string(), }); -export const ngnCustomerStatusQuerySchema = z.object({ - customerRef: z.string().openapi({ - description: - "Our identifier for the person, stable for the life of their install. " + - "Derived from the wallet address, never a provider id.", - example: "veil_a1b2c3d4e5f6", - }), -}); - -export const ngnCustomerStatusResponseSchema = z.object({ - customerRef: z.string(), - verified: z.boolean().openapi({ - description: - "Whether the provider verifies this reference right now. Read-only: " + - "asking does not create a customer and does not spend a verification " + - "attempt, which is the reason this route exists.", - }), - status: z.string().optional(), -}); - - // ─── GET /fees ────────────────────────────────────────────────────────────── - export const feesQuerySchema = z.object({ amount: optionalQueryString( "The amount being sent, in whole units of the asset. When given and a percentage " + - "fee is configured, each fee is computed for it; without it each fee is the " + - "floor. Optional because shipped clients predate it.", + "fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.", ).openapi({ example: "25" }), }); @@ -831,3 +815,27 @@ export const feesResponseSchema = z.object({ description: "Keyed by asset code. Always charged in the asset being sent.", }), }); + +export const ngnCustomerStatusQuerySchema = z.object({ + customerRef: z.string().openapi({ + description: + "Our identifier for the person, stable for the life of their install. " + + "Derived from the wallet address, never a provider id.", + example: "veil_a1b2c3d4e5f6", + }), +}); + +export const ngnCustomerStatusResponseSchema = z.object({ + customerRef: z.string(), + verified: z.boolean().openapi({ + description: + "Whether the provider verifies this reference right now. Read-only: " + + "asking does not create a customer and does not spend a verification " + + "attempt, which is the reason this route exists.", + }), + status: z.string().optional(), +}); + + + +