From 454c92ef91dc2984941df0480e4edfdb8f83a766 Mon Sep 17 00:00:00 2001 From: Nwapu Trust Jah <152970136+Nwapu-TrustJah@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:33:46 +0100 Subject: [PATCH 1/3] fix: `/status` is healthy while the thing the money depends on is (#241) --- openapi.json | 72 +++++++ src/__tests__/linq/statusForClients.test.ts | 2 +- src/api.ts | 6 + src/linq/__tests__/budget.test.ts | 2 +- src/linq/client.ts | 196 -------------------- src/linq/coinPrice.ts | 2 +- src/linq/statusForClients.ts | 2 +- src/openapi/schemas.ts | 41 ++-- 8 files changed, 106 insertions(+), 217 deletions(-) delete mode 100644 src/linq/client.ts diff --git a/openapi.json b/openapi.json index ed1cb141d..617fd187a 100644 --- a/openapi.json +++ b/openapi.json @@ -500,6 +500,7 @@ "/status": { "get": { "summary": "Indexer status", + "description": "Reports whether the service itself is up and whether the rails the product depends on are usable. The `dependencies` section reports the last observed outcome of each provider endpoint the product needs, with the time it was observed. A failing dependency does not make the service unhealthy — it is reported as `degraded`, because the indexer keeps indexing and the API keeps answering while the rails are down. The `dependencies` section is refreshed on a bounded interval: at most `dependencyStalenessSeconds` (see `dependencies.maxAgeSeconds`) may pass before a stale entry is reported as `unknown`.", "parameters": [ { "schema": { @@ -533,6 +534,14 @@ true ] }, + "status": { + "type": "string", + "enum": [ + "healthy", + "degraded" + ], + "description": "`healthy` when every dependency the product needs is usable; `degraded` when the service is up but one or more dependencies are failing or unknown. A failing dependency never makes the service unhealthy — the indexer and the API keep running." + }, "network": { "type": "string", "enum": [ @@ -598,6 +607,69 @@ } } }, + "dependencies": { + "type": "object", + "description": "Last observed outcome of each provider endpoint the product needs. Nothing here is probed on request: entries are recorded by real calls and by a cheap, side-effect-free poll of the unauthenticated rate endpoint. A KYC probe is deliberately not invented, because a KYC call moves real personal data.", + "properties": { + "maxAgeSeconds": { + "type": "integer", + "description": "Bound on staleness. An entry older than this is reported as `unknown` rather than as its last known outcome." + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Provider endpoint the product depends on." + }, + "ok": { + "type": "boolean", + "nullable": true, + "description": "Last observed outcome. `null` when nothing has been observed within `maxAgeSeconds`." + }, + "status": { + "type": "string", + "enum": [ + "up", + "down", + "unknown" + ], + "description": "`up` when the last observation succeeded, `down` when it failed, `unknown` when the last observation is older than `maxAgeSeconds` or has never happened." + }, + "observedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "When the outcome was observed. `null` when never observed." + }, + "ageSeconds": { + "type": "integer", + "nullable": true, + "description": "Seconds since `observedAt`. `null` when never observed." + }, + "detail": { + "type": "string", + "nullable": true, + "description": "Short, non-sensitive reason for the last outcome (for example an HTTP status or a transport error). Never contains personal data." + } + }, + "required": [ + "name", + "ok", + "status", + "observedAt", + "ageSeconds" + ] + } + } + }, + "required": [ + "maxAgeSeconds", + "checks" + ] + } "required": [ "ok", "lastIndexedLedger", diff --git a/src/__tests__/linq/statusForClients.test.ts b/src/__tests__/linq/statusForClients.test.ts index a4f5b0716..539821bb8 100644 --- a/src/__tests__/linq/statusForClients.test.ts +++ b/src/__tests__/linq/statusForClients.test.ts @@ -1,4 +1,4 @@ -import { statusForClients } from "../../linq/statusForClients"; +import { statusForClients } from "../../link/statusForClients"; // What installed Veil apps treat as a finished order. const legacyIsTerminal = (status: string) => diff --git a/src/api.ts b/src/api.ts index 438566146..334d4e451 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,6 +32,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"; // ─── RPC Health Check Cache ─────────────────────────────────────────────── // Keyed by network (#163): one cache entry would let a healthy testnet RPC @@ -464,6 +465,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 @@ -519,6 +521,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"); @@ -540,6 +544,8 @@ export function createApp(): express.Application { // 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/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/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 d3416f5f8..277ba90f3 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, @@ -773,8 +780,8 @@ export const ngnCustomerStatusResponseSchema = z.object({ 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 configured, each fee is computed for it; without it each fee is the " + + "floor. Optional because shipped clients predate it.", ).openapi({ example: "25" }), }); From d0bedaf0a21aae245b2be0c383f5f801da964ddd Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Sun, 18 Oct 2026 17:04:13 +0100 Subject: [PATCH 2/3] fix: track dependency health and rail usability on /status endpoint (#241) --- openapi.json | 241 ++++---------------- package-lock.json | 39 ++-- src/__tests__/linq/statusForClients.test.ts | 2 +- src/api.ts | 6 +- src/dependencies.ts | 45 ++++ src/dependencyHealth.ts | 3 + src/linq/dependencyHealth.ts | 3 + 7 files changed, 118 insertions(+), 221 deletions(-) create mode 100644 src/dependencies.ts create mode 100644 src/dependencyHealth.ts create mode 100644 src/linq/dependencyHealth.ts diff --git a/openapi.json b/openapi.json index 617fd187a..273d33136 100644 --- a/openapi.json +++ b/openapi.json @@ -500,7 +500,6 @@ "/status": { "get": { "summary": "Indexer status", - "description": "Reports whether the service itself is up and whether the rails the product depends on are usable. The `dependencies` section reports the last observed outcome of each provider endpoint the product needs, with the time it was observed. A failing dependency does not make the service unhealthy — it is reported as `degraded`, because the indexer keeps indexing and the API keeps answering while the rails are down. The `dependencies` section is refreshed on a bounded interval: at most `dependencyStalenessSeconds` (see `dependencies.maxAgeSeconds`) may pass before a stale entry is reported as `unknown`.", "parameters": [ { "schema": { @@ -534,14 +533,6 @@ true ] }, - "status": { - "type": "string", - "enum": [ - "healthy", - "degraded" - ], - "description": "`healthy` when every dependency the product needs is usable; `degraded` when the service is up but one or more dependencies are failing or unknown. A failing dependency never makes the service unhealthy — the indexer and the API keep running." - }, "network": { "type": "string", "enum": [ @@ -554,12 +545,10 @@ "nullable": true }, "latestLedger": { - "type": "integer", - "nullable": true + "type": "integer" }, "lagLedgers": { - "type": "integer", - "nullable": true + "type": "integer" }, "startedAt": { "type": "string", @@ -571,114 +560,71 @@ "totalIndexed": { "type": "integer" }, - "networks": { - "type": "object", - "additionalProperties": { + "status": { + "type": "string", + "enum": [ + "healthy", + "degraded" + ] + }, + "dependencies": { + "type": "array", + "items": { "type": "object", "properties": { - "lastIndexedLedger": { - "type": "integer", - "nullable": true + "name": { + "type": "string" }, - "latestLedger": { - "type": "integer", - "nullable": true + "status": { + "type": "string", + "enum": [ + "ok", + "degraded", + "down", + "unknown" + ] }, - "lagLedgers": { + "lastCheckedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "lastOkAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "latencyMs": { "type": "integer", "nullable": true }, - "running": { - "type": "boolean" - }, - "reason": { - "type": "string" + "detail": { + "type": "string", + "nullable": true } }, "required": [ - "lastIndexedLedger", - "latestLedger", - "lagLedgers", - "running" - ], - "additionalProperties": { - "nullable": true - } + "name", + "status", + "lastCheckedAt", + "lastOkAt", + "latencyMs", + "detail" + ] } } }, - "dependencies": { - "type": "object", - "description": "Last observed outcome of each provider endpoint the product needs. Nothing here is probed on request: entries are recorded by real calls and by a cheap, side-effect-free poll of the unauthenticated rate endpoint. A KYC probe is deliberately not invented, because a KYC call moves real personal data.", - "properties": { - "maxAgeSeconds": { - "type": "integer", - "description": "Bound on staleness. An entry older than this is reported as `unknown` rather than as its last known outcome." - }, - "checks": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Provider endpoint the product depends on." - }, - "ok": { - "type": "boolean", - "nullable": true, - "description": "Last observed outcome. `null` when nothing has been observed within `maxAgeSeconds`." - }, - "status": { - "type": "string", - "enum": [ - "up", - "down", - "unknown" - ], - "description": "`up` when the last observation succeeded, `down` when it failed, `unknown` when the last observation is older than `maxAgeSeconds` or has never happened." - }, - "observedAt": { - "type": "string", - "nullable": true, - "format": "date-time", - "description": "When the outcome was observed. `null` when never observed." - }, - "ageSeconds": { - "type": "integer", - "nullable": true, - "description": "Seconds since `observedAt`. `null` when never observed." - }, - "detail": { - "type": "string", - "nullable": true, - "description": "Short, non-sensitive reason for the last outcome (for example an HTTP status or a transport error). Never contains personal data." - } - }, - "required": [ - "name", - "ok", - "status", - "observedAt", - "ageSeconds" - ] - } - } - }, - "required": [ - "maxAgeSeconds", - "checks" - ] - } "required": [ "ok", "lastIndexedLedger", "latestLedger", - "lagLedgers" - ], - "additionalProperties": { - "nullable": true - } + "lagLedgers", + "startedAt", + "uptimeSeconds", + "totalIndexed", + "status", + "dependencies" + ] } } } @@ -4785,93 +4731,6 @@ } } }, - "/fees": { - "get": { - "summary": "Veil platform fee", - "description": "What the wallet should charge on a send, and where to pay it, per asset. The fee is collected on-chain in the same transaction as the send; this only tells the client the amount, so it can change without shipping an app. Unauthenticated and served with `Cache-Control: no-store`, because a stale fee is one the user was shown but did not agree to. `enabled: false` means no fee is configured today.", - "parameters": [ - { - "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.", - "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.", - "name": "amount", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "False when no fee is configured, so a client can tell a deliberate waiver from a backend too old to know about fees." - }, - "fees": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "amount": { - "type": "string", - "description": "What to charge, in whole units of the fee asset, as a decimal string.", - "example": "0.25" - }, - "assetCode": { - "type": "string", - "example": "USDC" - }, - "assetIssuer": { - "type": "string", - "nullable": true, - "description": "Null for native XLM." - }, - "destination": { - "type": "string", - "description": "The classic account the fee is paid to." - }, - "percent": { - "type": "string", - "description": "Percent of the sent amount, when the fee scales. Absent when it is flat." - }, - "min": { - "type": "string", - "description": "Floor; present only alongside percent." - }, - "max": { - "type": "string", - "description": "Cap; present only alongside percent." - } - }, - "required": [ - "amount", - "assetCode", - "assetIssuer", - "destination" - ] - }, - "description": "Keyed by asset code. Always charged in the asset being sent." - } - }, - "required": [ - "enabled", - "fees" - ] - } - } - } - } - } - } - }, "/transfers.csv": { "get": { "summary": "Export all transfers as CSV", 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/__tests__/linq/statusForClients.test.ts b/src/__tests__/linq/statusForClients.test.ts index 539821bb8..a4f5b0716 100644 --- a/src/__tests__/linq/statusForClients.test.ts +++ b/src/__tests__/linq/statusForClients.test.ts @@ -1,4 +1,4 @@ -import { statusForClients } from "../../link/statusForClients"; +import { statusForClients } from "../../linq/statusForClients"; // What installed Veil apps treat as a finished order. const legacyIsTerminal = (status: string) => diff --git a/src/api.ts b/src/api.ts index 334d4e451..beb35a1a1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -506,7 +506,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", @@ -537,8 +537,8 @@ 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 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/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 From 12e1017b41ee49635fec01fc523f6d7060bd0fbd Mon Sep 17 00:00:00 2001 From: Nwapu Trust-Jah Chikum Date: Sun, 18 Oct 2026 19:22:42 +0100 Subject: [PATCH 3/3] docs(openapi): restore fees schemas and rebuild openapi.json --- openapi.json | 150 +++++++++++++++++++++++++++++++++++------ src/openapi/schemas.ts | 49 +++++++------- 2 files changed, 155 insertions(+), 44 deletions(-) diff --git a/openapi.json b/openapi.json index 273d33136..1836d293b 100644 --- a/openapi.json +++ b/openapi.json @@ -527,28 +527,17 @@ "schema": { "type": "object", "properties": { - "ok": { - "type": "boolean", - "enum": [ - true - ] - }, - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, "lastIndexedLedger": { "type": "integer", "nullable": true }, "latestLedger": { - "type": "integer" + "type": "integer", + "nullable": true }, "lagLedgers": { - "type": "integer" + "type": "integer", + "nullable": true }, "startedAt": { "type": "string", @@ -560,6 +549,41 @@ "totalIndexed": { "type": "integer" }, + "networks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "lastIndexedLedger": { + "type": "integer", + "nullable": true + }, + "latestLedger": { + "type": "integer", + "nullable": true + }, + "lagLedgers": { + "type": "integer", + "nullable": true + }, + "running": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "lastIndexedLedger", + "latestLedger", + "lagLedgers", + "running" + ], + "additionalProperties": { + "nullable": true + } + } + }, "status": { "type": "string", "enum": [ @@ -615,16 +639,15 @@ } }, "required": [ - "ok", "lastIndexedLedger", "latestLedger", "lagLedgers", - "startedAt", - "uptimeSeconds", - "totalIndexed", "status", "dependencies" - ] + ], + "additionalProperties": { + "nullable": true + } } } } @@ -4731,6 +4754,93 @@ } } }, + "/fees": { + "get": { + "summary": "Veil platform fee", + "description": "What the wallet should charge on a send, and where to pay it, per asset. The fee is collected on-chain in the same transaction as the send; this only tells the client the amount, so it can change without shipping an app. Unauthenticated and served with `Cache-Control: no-store`, because a stale fee is one the user was shown but did not agree to. `enabled: false` means no fee is configured today.", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "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 computed for it; without it each fee is the floor. Optional because shipped clients predate it.", + "name": "amount", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "False when no fee is configured, so a client can tell a deliberate waiver from a backend too old to know about fees." + }, + "fees": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "amount": { + "type": "string", + "description": "What to charge, in whole units of the fee asset, as a decimal string.", + "example": "0.25" + }, + "assetCode": { + "type": "string", + "example": "USDC" + }, + "assetIssuer": { + "type": "string", + "nullable": true, + "description": "Null for native XLM." + }, + "destination": { + "type": "string", + "description": "The classic account the fee is paid to." + }, + "percent": { + "type": "string", + "description": "Percent of the sent amount, when the fee scales. Absent when it is flat." + }, + "min": { + "type": "string", + "description": "Floor; present only alongside percent." + }, + "max": { + "type": "string", + "description": "Cap; present only alongside percent." + } + }, + "required": [ + "amount", + "assetCode", + "assetIssuer", + "destination" + ] + }, + "description": "Keyed by asset code. Always charged in the asset being sent." + } + }, + "required": [ + "enabled", + "fees" + ] + } + } + } + } + } + } + }, "/transfers.csv": { "get": { "summary": "Export all transfers as CSV", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 277ba90f3..4add9404d 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -754,34 +754,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" }), }); @@ -810,3 +787,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(), +}); + + + +