From 615d09aec79e1b260aeacd1727217dcd46cb35be Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Fri, 16 Oct 2026 01:06:21 +0100 Subject: [PATCH 1/9] fix: correct native Stellar SAC defaults --- .env.example | 14 +++++++------- src/__tests__/multiNetworkIndexer.test.ts | 6 ++++++ src/__tests__/sacDetect.test.ts | 13 ++++++++----- src/indexer.ts | 4 ++-- src/indexer/sac-detect.ts | 4 ++-- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 1d8ed5ef..2f773a89 100644 --- a/.env.example +++ b/.env.example @@ -45,8 +45,8 @@ POLL_INTERVAL_MS=6000 # ─── SAC Contract IDs ──────────────────────────────────────── # Comma-separated list of Soroban SAC (Stellar Asset Contract) IDs to watch. # When unset, defaults to the native XLM SAC for the configured STELLAR_NETWORK: -# testnet → CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4 -# mainnet → CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# testnet → CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# mainnet → CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA # # How to derive a SAC address for any Stellar asset: # import { Asset, Networks } from '@stellar/stellar-sdk'; @@ -57,15 +57,15 @@ POLL_INTERVAL_MS=6000 # .contractId(Networks.PUBLIC); # # Known mainnet SAC addresses: -# XLM (native): CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC -# USDC (Centre): CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA +# XLM (native): CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA +# USDC (Centre): CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75 # # Example (watch XLM + USDC on mainnet): -# SAC_CONTRACT_IDS=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC,CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA +# SAC_CONTRACT_IDS=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA,CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75 # # Per-network watch lists (for dual-network deployments when NETWORKS=testnet,mainnet): -# SAC_CONTRACT_IDS_TESTNET=CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4 -# SAC_CONTRACT_IDS_MAINNET=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# SAC_CONTRACT_IDS_TESTNET=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC +# SAC_CONTRACT_IDS_MAINNET=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA # # Soroban RPC's getEvents accepts multiple contract IDs in a single request — # there is no N+1 query penalty for watching multiple contracts. diff --git a/src/__tests__/multiNetworkIndexer.test.ts b/src/__tests__/multiNetworkIndexer.test.ts index 28968385..5bad02bc 100644 --- a/src/__tests__/multiNetworkIndexer.test.ts +++ b/src/__tests__/multiNetworkIndexer.test.ts @@ -17,6 +17,7 @@ import { runningNetworks, _resetIndexerLoops, } from "../indexer"; +import { Asset, Networks } from "@stellar/stellar-sdk"; import { getRpc, validateNetworkConfig, _resetRpcClients } from "../rpc"; import { currentNetwork, enabledNetworks, parseNetwork } from "../network"; @@ -82,6 +83,11 @@ describe("enabledNetworks", () => { }); describe("per-network watch lists", () => { + it("matches the Stellar SDK's native SAC derivation", () => { + expect(DEFAULT_XLM_SAC_MAINNET).toBe(Asset.native().contractId(Networks.PUBLIC)); + expect(DEFAULT_XLM_SAC_TESTNET).toBe(Asset.native().contractId(Networks.TESTNET)); + }); + it("defaults each network to its own native XLM SAC", () => { // The bug this prevents: reading STELLAR_NETWORK inside the resolver, so // both loops watch the same chain's SAC and one indexes nothing. diff --git a/src/__tests__/sacDetect.test.ts b/src/__tests__/sacDetect.test.ts index 8082d6d7..d2c761d9 100644 --- a/src/__tests__/sacDetect.test.ts +++ b/src/__tests__/sacDetect.test.ts @@ -7,6 +7,7 @@ */ import { xdr } from "@stellar/stellar-sdk"; +import { DEFAULT_XLM_SAC_MAINNET, DEFAULT_XLM_SAC_TESTNET } from "../indexer"; import { executableIsSac, instanceValIsSac, @@ -17,9 +18,6 @@ import { type InstanceFetcher, } from "../indexer/sac-detect"; -// The native XLM SACs are hard-coded as known SACs in the module. -const TESTNET_XLM_SAC = "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4"; - function sacInstanceVal(): xdr.ScVal { return xdr.ScVal.scvContractInstance( new xdr.ScContractInstance({ @@ -67,9 +65,14 @@ describe("instanceValIsSac", () => { }); describe("detectSac", () => { - it("returns true for a known native XLM SAC without fetching", async () => { + it("returns true for known native XLM SACs without fetching", async () => { const fetcher = jest.fn, [string]>(); - await expect(detectSac(TESTNET_XLM_SAC, fetcher)).resolves.toBe(true); + await expect( + Promise.all([ + detectSac(DEFAULT_XLM_SAC_MAINNET, fetcher), + detectSac(DEFAULT_XLM_SAC_TESTNET, fetcher), + ]), + ).resolves.toEqual([true, true]); expect(fetcher).not.toHaveBeenCalled(); }); diff --git a/src/indexer.ts b/src/indexer.ts index 03363be7..c9e4a859 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -34,9 +34,9 @@ export function resolveNftContractIds(network?: Network): string[] { // These are derived from Asset.native().contractId(Networks.PUBLIC / Networks.TESTNET) // and serve as the backwards-compatible default when SAC_CONTRACT_IDS is unset. export const DEFAULT_XLM_SAC_MAINNET = - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; export const DEFAULT_XLM_SAC_TESTNET = - "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4"; + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; /** * Resolve the list of SAC contract IDs to watch. diff --git a/src/indexer/sac-detect.ts b/src/indexer/sac-detect.ts index 6687e6ae..a1a934a8 100644 --- a/src/indexer/sac-detect.ts +++ b/src/indexer/sac-detect.ts @@ -26,8 +26,8 @@ import { resolveNetwork, type Network } from "../network"; // The native XLM SAC on mainnet and testnet. These are fixed by the network and // never change, so we short-circuit detection (and any RPC call) for them. const KNOWN_SAC_IDS = new Set([ - "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // mainnet native XLM - "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4", // testnet native XLM + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", // mainnet native XLM + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // testnet native XLM ]); // ─── Pure helpers ───────────────────────────────────────────────────────────── From 8044d75d5fda9c6756e39b79fdc7df90faeb01ba Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sat, 17 Oct 2026 00:44:40 +0100 Subject: [PATCH 2/9] fix(offramp): require coin on /offramp/rate, return NGN per coin (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /offramp/rate previously called Linq's /b2b/rate which always returns naira per dollar. Dividing a naira amount by that gives the right USDC figure by coincidence (1 USDC = 1 USD) but the wrong figure for any other asset by exactly that asset's price — the same defect that caused the onramp to buy 0.6 XLM instead of 3.6 for a 1000 NGN purchase. Changes: - src/api/ngn.ts: export ngnPerCoin so the offramp can reuse the same price path - src/api/offramp.ts: require coin query param (400 without it), call ngnPerCoin(), cache per-coin, response is { coin, rate, indicative: true } - src/__tests__/routes/offramp.test.ts: add 7 tests pinning XLM vs USDC rates differ, 400 on missing/unknown coin, no currency field, 502 on pricing failure - src/openapi/schemas.ts: add offrampRateQuerySchema, update offrampRateResponseSchema (coin enum, rate description, drop currency) - src/openapi/build.ts: add request.query to /offramp/rate registration, update summary - openapi.json: regenerated to match new schema --- openapi.json | 38 ++++++++++---- src/__tests__/routes/offramp.test.ts | 76 ++++++++++++++++++++++++++++ src/api/ngn.ts | 5 +- src/api/offramp.ts | 48 ++++++++++++++---- src/openapi/build.ts | 4 +- src/openapi/schemas.ts | 17 +++++-- 6 files changed, 162 insertions(+), 26 deletions(-) diff --git a/openapi.json b/openapi.json index f807dbbb..967b8f43 100644 --- a/openapi.json +++ b/openapi.json @@ -5114,9 +5114,26 @@ }, "/offramp/rate": { "get": { - "summary": "Indicative NGN/USDC rate", + "summary": "Indicative naira-per-coin 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, + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "xlm", + "usdc" + ], + "description": "Asset to price. Required: omitting it would silently return a dollar rate.", + "example": "usdc" + }, + "required": true, + "description": "Asset to price. Required: omitting it would silently return a dollar rate.", + "name": "coin", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -5125,14 +5142,16 @@ "schema": { "type": "object", "properties": { - "rate": { - "type": "number" - }, - "currency": { - "type": "string" - }, "coin": { - "type": "string" + "type": "string", + "enum": [ + "xlm", + "usdc" + ] + }, + "rate": { + "type": "number", + "description": "Naira per one unit of the requested coin, not naira per dollar." }, "indicative": { "type": "boolean", @@ -5142,9 +5161,8 @@ } }, "required": [ - "rate", - "currency", "coin", + "rate", "indicative" ] } diff --git a/src/__tests__/routes/offramp.test.ts b/src/__tests__/routes/offramp.test.ts index 5ddbe85c..802e6817 100644 --- a/src/__tests__/routes/offramp.test.ts +++ b/src/__tests__/routes/offramp.test.ts @@ -48,9 +48,14 @@ jest.mock("../../linq/client", () => ({ getOfframpStatus: jest.fn(), })); +jest.mock("../../api/ngn", () => ({ + ngnPerCoin: jest.fn(), +})); + import { createOfframpRouter } from "../../api/offramp"; import { networkMiddleware } from "../../middleware/network"; import { LinqError, createOfframpOrder, getOfframpStatus } from "../../linq/client"; +import { ngnPerCoin } from "../../api/ngn"; const mockCreate = createOfframpOrder as jest.Mock; const mockStatus = getOfframpStatus as jest.Mock; @@ -463,3 +468,74 @@ describe("retrying an order creation", () => { expect(JSON.stringify(res.body)).not.toContain(LINQ_ID); }); }); + +const mockNgnPerCoin = ngnPerCoin as jest.Mock; + +describe("GET /offramp/rate — naira per coin, not naira per dollar", () => { + // The bug this fixes: the old handler called getRate() which returns Linq's + // /b2b/rate number — naira per dollar. Dividing a naira amount by that + // gives the right USDC figure by coincidence (1 USDC = $1) and the wrong + // figure for every other asset by that asset's price. + + beforeEach(() => { + // 1,655 NGN per USDC (dollar-pegged, so same as NGN/USD) + // 290 NGN per XLM (XLM ≈ $0.175 × 1,655) + mockNgnPerCoin.mockImplementation(async (coin: string) => + coin === "usdc" ? 1655 : 290, + ); + }); + + function rateUrl(coin?: string) { + const app = makeApp(); + const req = request(app).get("/offramp/rate"); + return coin ? req.query({ coin }) : req; + } + + it("returns naira per XLM for coin=xlm", async () => { + const res = await rateUrl("xlm"); + expect(res.status).toBe(200); + expect(res.body.coin).toBe("xlm"); + expect(res.body.rate).toBeCloseTo(290, 6); + expect(res.body.indicative).toBe(true); + expect(mockNgnPerCoin).toHaveBeenCalledWith("xlm"); + }); + + it("returns naira per USDC for coin=usdc", async () => { + const res = await rateUrl("usdc"); + expect(res.status).toBe(200); + expect(res.body.coin).toBe("usdc"); + expect(res.body.rate).toBeCloseTo(1655, 6); + expect(res.body.indicative).toBe(true); + expect(mockNgnPerCoin).toHaveBeenCalledWith("usdc"); + }); + + it("XLM and USDC rates differ — pins that the old dollar rate is gone", async () => { + const xlm = await rateUrl("xlm"); + const usdc = await rateUrl("usdc"); + expect(xlm.body.rate).not.toBeCloseTo(usdc.body.rate, 0); + }); + + it("refuses a request with no coin — cannot silently return a dollar rate", async () => { + const res = await rateUrl(); + expect(res.status).toBe(400); + expect(mockNgnPerCoin).not.toHaveBeenCalled(); + }); + + it("refuses an unknown coin", async () => { + const res = await rateUrl("doge"); + expect(res.status).toBe(400); + expect(mockNgnPerCoin).not.toHaveBeenCalled(); + }); + + it("the response carries no currency field — rate is already in NGN", async () => { + const res = await rateUrl("usdc"); + expect(res.status).toBe(200); + expect(res.body).not.toHaveProperty("currency"); + }); + + it("surfaces a pricing failure rather than returning a stale rate", async () => { + mockNgnPerCoin.mockRejectedValueOnce(new LinqError("Could not price XLM right now", 502)); + const res = await rateUrl("xlm"); + expect(res.status).toBe(502); + }); +}); diff --git a/src/api/ngn.ts b/src/api/ngn.ts index 60188e95..89d9e712 100644 --- a/src/api/ngn.ts +++ b/src/api/ngn.ts @@ -244,8 +244,11 @@ export function __clearNgnRateCache(): void { * Linq gives naira per dollar; this turns it into naira per coin. Every naira * amount on both rails is denominated this way, so the conversion happens in * exactly one place and the rate we quote is the rate we send Linq. + * + * Exported so the offramp rate endpoint can reuse the same price path rather + * than opening a second route to the provider. */ -async function ngnPerCoin(coin: OnrampCoin): Promise { +export async function ngnPerCoin(coin: OnrampCoin): Promise { const [ngnPerUsd, usd] = await Promise.all([ngnPerUsdCached(), usdPerCoin(coin)]); const rate = ngnPerUsd * usd; if (!Number.isFinite(rate) || rate <= 0) { diff --git a/src/api/offramp.ts b/src/api/offramp.ts index b233079a..7db022ba 100644 --- a/src/api/offramp.ts +++ b/src/api/offramp.ts @@ -15,9 +15,10 @@ import { checkStellarTrustline, createOfframpOrder, getOfframpStatus, - getRate, verifyBank, } from "../linq/client"; +import { ngnPerCoin } from "./ngn"; +import { type OnrampCoin } from "../linq/onramp"; /** * Offramp router - mounts at /offramp @@ -110,24 +111,49 @@ export function createOfframpRouter( next(); }); - // Every cash-out screen asks for the rate when it opens, and each ask spends - // the provider allowance all users share. The rate is indicative (the binding - // one is locked into the order), so a few seconds of reuse costs nothing. + // `coin` is required, for the same reason the onramp refuses without one. + // + // Linq's /b2b/rate returns naira per dollar regardless of which coin is + // asked about. A client that calls this without a coin and divides a naira + // amount by the answer gets the right number of USDC (a USDC is a dollar), + // but the wrong number of XLM by exactly the price of XLM. The onramp had + // the identical defect and it cost real money: ₦1,000 bought 0.6 XLM + // instead of 3.6. Defaulting here would reproduce that silently, so the + // only safe answer to a missing coin is a 400. + // + // The rate is computed by ngnPerCoin (shared with the onramp), which + // multiplies the NGN/USD rate by the coin's USD price so both rails quote + // from one source. + // + // Per-coin cache: every cash-out screen asks for the rate when it opens, and + // each ask spends the provider allowance all users share. The rate here is + // indicative (the binding one is locked into the order), so a few seconds of + // reuse costs nothing. Coins are cached separately so an XLM quote does not + // evict a USDC quote that arrived a moment before. + const COINS: Record = { xlm: "xlm", usdc: "usdc" }; const RATE_CACHE_MS = 20_000; - let rateCache: { at: number; body: unknown } | null = null; + const rateCacheByCoins = new Map(); - router.get("/rate", async (_req: Request, res: Response) => { - if (rateCache && Date.now() - rateCache.at < RATE_CACHE_MS) { - res.json(rateCache.body); + router.get("/rate", async (req: Request, res: Response) => { + const resolvedCoin = COINS[String(req.query["coin"] ?? "").toLowerCase()]; + if (!resolvedCoin) { + res.status(400).json({ error: `coin must be one of: ${Object.keys(COINS).join(", ")}` }); return; } + + const cached = rateCacheByCoins.get(resolvedCoin); + if (cached && Date.now() - cached.at < RATE_CACHE_MS) { + res.json(cached.body); + return; + } + try { - const rate = await getRate(); + const rate = await ngnPerCoin(resolvedCoin); // Flagged indicative on purpose: the binding rate is the one locked into // an order at creation, and a client that caches this one quotes a // number the payout will not honour. - const body = { ...rate, indicative: true }; - rateCache = { at: Date.now(), body }; + const body = { coin: resolvedCoin, rate, indicative: true }; + rateCacheByCoins.set(resolvedCoin, { at: Date.now(), body }); res.json(body); } catch (err) { sendLinqError(res, err); diff --git a/src/openapi/build.ts b/src/openapi/build.ts index 7486c938..b1f448c0 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -40,6 +40,7 @@ import { transferExportQuerySchema, binaryExportResponseSchema, linqWebhookAckResponseSchema, + offrampRateQuerySchema, offrampRateResponseSchema, offrampVerifyBankBodySchema, offrampVerifiedBankSchema, @@ -465,9 +466,10 @@ const internalErrorResponses = { registry.registerPath({ method: "get", path: "/offramp/rate", - summary: "Indicative NGN/USDC rate", + summary: "Indicative naira-per-coin rate", description: internalDescription, "x-internal": true, + request: { query: offrampRateQuerySchema }, responses: { 200: { description: "OK", content: { "application/json": { schema: offrampRateResponseSchema } } }, ...internalErrorResponses, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 6e7bdc17..027a707f 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -635,10 +635,21 @@ export const linqWebhookAckResponseSchema = z.object({ // ─── /offramp/* — cash-out surface (internal) ─────────────────────────────── +export const offrampRateQuerySchema = z + .object({ + /** Which asset to price. Required — defaulting silently to the dollar rate was the bug. */ + coin: z.enum(["xlm", "usdc"]).openapi({ + description: "Asset to price. Required: omitting it would silently return a dollar rate.", + example: "usdc", + }), + }) + .passthrough(); + export const offrampRateResponseSchema = z.object({ - rate: z.number(), - currency: z.string(), - coin: z.string(), + coin: z.enum(["xlm", "usdc"]), + rate: z.number().openapi({ + description: "Naira per one unit of the requested coin, not naira per dollar.", + }), /** Always true: the binding rate is the one locked into an order at creation. */ indicative: z.literal(true), }); From d9ba8c7cf93aa34dad809f9214767b343cabad5e Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 14:01:35 +0100 Subject: [PATCH 3/9] fix(offramp): stage the coin requirement instead of cutting off installed APKs A coin-less request to /offramp/rate is how every installed build asks for the rate, and the mobile client reads any error there as "cash-out is down" and hides the tile. Refusing it does not make an old APK price XLM correctly - it stops that build pricing at all, for every user on it, until they happen to update. #216 was this mistake and the recorded lesson is ship the client first. Defaulting for a missing coin is not the silent dollar-rate bug this endpoint was built to kill: ngnPerCoin("usdc") *is* naira per dollar, so a coin-less caller gets back exactly the number it gets today, and the response now names the coin it was priced for so a client can tell. Making the server strict cannot fix clients that are not in this repository; only shipping them can. Changes: - src/api/offramp.ts: an unknown or blank coin still 400s (new behaviour, nothing depends on it). A missing coin is priced for usdc and logged as deprecated, once per User-Agent, so the log lists the callers still asking rather than flooding. ngnPerCoin, the shared price path, and the per-coin cache are unchanged. - src/openapi/schemas.ts: coin is optional in the query schema, with the deprecation and the switch-over condition in its description; the response's coin field says what it is quoted per. No OpenAPI `deprecated` flag - these routes are live, and src/__tests__/openapiCoverage.test.ts pins that. - src/__tests__/routes/offramp.test.ts: the coin-less request now asserts the USDC answer, the coin it is labelled with, one deprecation log per caller, and 400 for unknown/blank/repeated coin. Explicit-coin cases are untouched. - docs/offramp-orders.md: the three stages, and the gate on stage 3 - a build carrying the client change must be out and the log line quiet. Stage 2 (send coin, and stop using this endpoint as the availability probe) lives in the wallet repos, not here. - openapi.json, clients/react-query/src/schema.d.ts: hand-updated to match, because this checkout has no node runtime or node_modules. Regenerate with `npm run docs:openapi` and `npm run generate -w clients/react-query` before merging; schema.d.ts was already stale from the previous commit. --- clients/react-query/src/schema.d.ts | 12 ++-- docs/offramp-orders.md | 50 +++++++++++++++++ openapi.json | 9 +-- src/__tests__/routes/offramp.test.ts | 66 ++++++++++++++++++++-- src/api/offramp.ts | 84 ++++++++++++++++++++++++---- src/openapi/schemas.ts | 16 ++++-- 6 files changed, 209 insertions(+), 28 deletions(-) diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index b86fd99f..9d335f2f 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -2460,12 +2460,15 @@ export interface paths { cookie?: never; }; /** - * Indicative NGN/USDC rate + * Indicative naira-per-coin 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; + query?: { + /** @description Asset to price. Send it: without one the answer is priced for `usdc`, which is naira per dollar and wrong for every other asset by that asset's price. Omitting it is deprecated and will be refused once the installed wallet builds ask for a coin; a response always names the coin it was priced for. */ + coin?: "xlm" | "usdc"; + }; header?: never; path?: never; cookie?: never; @@ -2479,9 +2482,10 @@ export interface paths { }; content: { "application/json": { + /** @description The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`. */ + coin: "xlm" | "usdc"; + /** @description Naira per one unit of the requested coin, not naira per dollar. */ rate: number; - currency: string; - coin: string; /** @enum {boolean} */ indicative: true; }; diff --git a/docs/offramp-orders.md b/docs/offramp-orders.md index e012a54b..7f787b9c 100644 --- a/docs/offramp-orders.md +++ b/docs/offramp-orders.md @@ -44,6 +44,56 @@ a **new** `accessToken`; the previous token stops working. A different `walletAddress` gets `409` and nothing about the order. Idempotency keys should therefore be random and generated by the client. +## GET /offramp/rate — `coin`, and how it becomes required + +``` +GET /offramp/rate?coin=xlm → 200 { coin: "xlm", rate: , indicative: true } +GET /offramp/rate?coin=usdc → 200 { coin: "usdc", rate: , indicative: true } +GET /offramp/rate → 200 { coin: "usdc", … } + a deprecation log line +GET /offramp/rate?coin=doge → 400 +GET /offramp/rate?coin= → 400 +``` + +`rate` is naira per **one unit of the coin named**, never naira per dollar, and +it comes from `ngnPerCoin()` — the same path the onramp quotes with, so the two +rails cannot disagree. The response always carries the coin it was priced for. + +Pricing a coin as dollars is the defect that cost real money on the onramp: ₦1,000 +bought 0.6 XLM instead of 3.6, because a dollar rate was divided as though it +were an XLM rate. So `coin` is on its way to being mandatory. + +### Why a missing coin is still answered + +Because installed APKs cannot be updated from this repository. The mobile client +asks `isOfframpAvailable()`, which calls this endpoint and reads **any** error as +"cash-out is down" and hides the tile. A 400 on a coin-less request therefore +does not tell anyone to pick a coin — it removes cash-out for every user on an +older build, silently, and looks like an outage. #216 was this mistake, and the +lesson recorded from it was: ship the client first. + +Answering is not the silent defaulting this endpoint exists to prevent. A USDC is +a dollar, so the USDC rate *is* the number a coin-less caller gets from today's +deployment: unchanged for the callers that cannot change yet, wrong for XLM in +exactly the way it is already wrong, and now labelled with the coin it is priced +for so a client can see the difference. + +### The three stages + +1. **Here.** An unknown coin 400s — new behaviour, nothing depends on it. A + missing coin is answered as `usdc` and logged as deprecated, once per + User-Agent, so the log reads as a list of callers rather than a flood. +2. **Clients.** Both wallet builds send `coin` on every rate call, including the + availability check — which should probe with an explicit coin, or better a + health route, because "is the service up" and "what is the rate for XLM" are + different questions and one endpoint should not be forced to answer both. +3. **Then.** When a build carrying stage 2 is actually released **and** the + deprecation line has been quiet for a full release cycle, a missing coin 400s + and `logCoinlessRateRequest` is deleted along with the compatibility default. + +Stage 3 is a breaking change to a money-moving endpoint. Do not make it from the +server side alone: it needs evidence from the log, not a judgement that enough +time has passed. + ## Upgrading - Orders created before this change have no `id` or token and cannot be read diff --git a/openapi.json b/openapi.json index 967b8f43..8c939498 100644 --- a/openapi.json +++ b/openapi.json @@ -5125,11 +5125,11 @@ "xlm", "usdc" ], - "description": "Asset to price. Required: omitting it would silently return a dollar rate.", + "description": "Asset to price. Send it: without one the answer is priced for `usdc`, which is naira per dollar and wrong for every other asset by that asset's price. Omitting it is deprecated and will be refused once the installed wallet builds ask for a coin; a response always names the coin it was priced for.", "example": "usdc" }, - "required": true, - "description": "Asset to price. Required: omitting it would silently return a dollar rate.", + "required": false, + "description": "Asset to price. Send it: without one the answer is priced for `usdc`, which is naira per dollar and wrong for every other asset by that asset's price. Omitting it is deprecated and will be refused once the installed wallet builds ask for a coin; a response always names the coin it was priced for.", "name": "coin", "in": "query" } @@ -5147,7 +5147,8 @@ "enum": [ "xlm", "usdc" - ] + ], + "description": "The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`." }, "rate": { "type": "number", diff --git a/src/__tests__/routes/offramp.test.ts b/src/__tests__/routes/offramp.test.ts index 802e6817..ec2d4345 100644 --- a/src/__tests__/routes/offramp.test.ts +++ b/src/__tests__/routes/offramp.test.ts @@ -52,7 +52,7 @@ jest.mock("../../api/ngn", () => ({ ngnPerCoin: jest.fn(), })); -import { createOfframpRouter } from "../../api/offramp"; +import { createOfframpRouter, __resetCoinlessRateLog } from "../../api/offramp"; import { networkMiddleware } from "../../middleware/network"; import { LinqError, createOfframpOrder, getOfframpStatus } from "../../linq/client"; import { ngnPerCoin } from "../../api/ngn"; @@ -476,6 +476,13 @@ describe("GET /offramp/rate — naira per coin, not naira per dollar", () => { // /b2b/rate number — naira per dollar. Dividing a naira amount by that // gives the right USDC figure by coincidence (1 USDC = $1) and the wrong // figure for every other asset by that asset's price. + // + // The rollout is staged because installed APKs cannot be updated from here: + // an unknown coin is refused today, while a missing coin is still answered — + // priced for USDC, which is the number those builds already get — and logged. + // See the note in api/offramp.ts. + + let warnings: string[]; beforeEach(() => { // 1,655 NGN per USDC (dollar-pegged, so same as NGN/USD) @@ -483,12 +490,21 @@ describe("GET /offramp/rate — naira per coin, not naira per dollar", () => { mockNgnPerCoin.mockImplementation(async (coin: string) => coin === "usdc" ? 1655 : 290, ); + warnings = []; + jest + .spyOn(console, "warn") + .mockImplementation((...args: unknown[]) => void warnings.push(args.join(" "))); + __resetCoinlessRateLog(); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); function rateUrl(coin?: string) { const app = makeApp(); const req = request(app).get("/offramp/rate"); - return coin ? req.query({ coin }) : req; + return coin === undefined ? req : req.query({ coin }); } it("returns naira per XLM for coin=xlm", async () => { @@ -515,18 +531,58 @@ describe("GET /offramp/rate — naira per coin, not naira per dollar", () => { expect(xlm.body.rate).not.toBeCloseTo(usdc.body.rate, 0); }); - it("refuses a request with no coin — cannot silently return a dollar rate", async () => { + it("answers a coin-less request with the USDC rate it gets today", async () => { + const res = await rateUrl(); + expect(res.status).toBe(200); + expect(res.body.rate).toBeCloseTo(1655, 6); + expect(mockNgnPerCoin).toHaveBeenCalledWith("usdc"); + }); + + it("names the coin a coin-less answer is priced for", async () => { + // The client-side half of the staged rollout depends on this: a build that + // asks for no coin can read which asset it was actually quoted for instead + // of assuming its dollar figure was a coin figure. const res = await rateUrl(); + expect(res.body.coin).toBe("usdc"); + }); + + it("logs a coin-less request as deprecated, once per caller", async () => { + await rateUrl(); + await rateUrl(); + const deprecation = warnings.filter((w) => w.includes("deprecated")); + expect(deprecation).toHaveLength(1); + expect(deprecation[0]).toContain("/offramp/rate without coin"); + expect(deprecation[0]).toContain("usdc"); + }); + + it("does not log a deprecation for a request that named a coin", async () => { + await rateUrl("xlm"); + expect(warnings.filter((w) => w.includes("deprecated"))).toHaveLength(0); + }); + + it("refuses an unknown coin — a coin we cannot price is not a default", async () => { + const res = await rateUrl("doge"); expect(res.status).toBe(400); expect(mockNgnPerCoin).not.toHaveBeenCalled(); }); - it("refuses an unknown coin", async () => { - const res = await rateUrl("doge"); + it("refuses an empty-string coin, which is a caller bug rather than an old APK", async () => { + // `?coin=` parses as an empty string: a client that built the query and + // lost the value must not be answered with a USDC number it will treat as + // XLM. Only a request that never sent the parameter is legacy. + const app = makeApp(); + const res = await request(app).get("/offramp/rate?coin="); expect(res.status).toBe(400); expect(mockNgnPerCoin).not.toHaveBeenCalled(); }); + it("accepts a coin in any case, so an old caller is not refused for casing", async () => { + const app = makeApp(); + const res = await request(app).get("/offramp/rate").query({ coin: "XLM" }); + expect(res.status).toBe(200); + expect(res.body.coin).toBe("xlm"); + }); + it("the response carries no currency field — rate is already in NGN", async () => { const res = await rateUrl("usdc"); expect(res.status).toBe(200); diff --git a/src/api/offramp.ts b/src/api/offramp.ts index 7db022ba..156d51a7 100644 --- a/src/api/offramp.ts +++ b/src/api/offramp.ts @@ -94,6 +94,39 @@ export function createOrderLookupLimiter(): RateLimitRequestHandler { }); } +/** + * The coin a coin-less rate request is priced for. + * + * A USDC is a dollar, so `ngnPerCoin("usdc")` is naira-per-dollar — the number + * `/offramp/rate` returned before `coin` existed. Defaulting to it changes + * nothing for the caller that omits the param; see the rollout note in the + * `/rate` handler for why refusing it is the wrong order of operations. + */ +const LEGACY_DEFAULT_COIN: OnrampCoin = "usdc"; + +// One line per caller, not one per request: every cash-out screen reload is a +// request, and a log that floods is a log nobody reads. The key is what a client +// sends as its User-Agent, which is enough to tell "the old APK" from "the web +// wallet forgot the param" — and it is not personal data, unlike an IP. This +// does not need to know *who* is asking, only whether anyone still is, and when +// they stop. +const legacyCoinCallers = new Set(); + +function logCoinlessRateRequest(req: Request): void { + const caller = req.get("user-agent") || "(no User-Agent)"; + if (legacyCoinCallers.has(caller)) return; + legacyCoinCallers.add(caller); + console.warn( + `[offramp] deprecated: GET /offramp/rate without coin from "${caller}" — priced for ` + + `${LEGACY_DEFAULT_COIN}. Sending coin will be required once every installed build does.`, + ); +} + +/** Test seam: the deprecation log fires once per caller for the process. */ +export function __resetCoinlessRateLog(): void { + legacyCoinCallers.clear(); +} + export function createOfframpRouter( options: { lookupLimiter?: RateLimitRequestHandler } = {}, ): Router { @@ -111,18 +144,36 @@ export function createOfframpRouter( next(); }); - // `coin` is required, for the same reason the onramp refuses without one. + // `coin` is the answer's subject, so the response always names it — that is + // what lets a client tell which asset it was quoted for instead of assuming. // - // Linq's /b2b/rate returns naira per dollar regardless of which coin is - // asked about. A client that calls this without a coin and divides a naira - // amount by the answer gets the right number of USDC (a USDC is a dollar), - // but the wrong number of XLM by exactly the price of XLM. The onramp had - // the identical defect and it cost real money: ₦1,000 bought 0.6 XLM - // instead of 3.6. Defaulting here would reproduce that silently, so the - // only safe answer to a missing coin is a 400. + // A request with **no** coin is still answered, with the USDC rate, for one + // reason: installed APKs. `isOfframpAvailable()` in the mobile client treats + // any error here as "cash-out is down" and hides the tile, so a 400 on the + // coin-less request does not ask anyone to pick a coin — it removes the + // feature for every user on an old build until they happen to update, and + // looks like an outage. #216 was this same mistake and the recorded lesson is + // ship the client first. // + // Answering is not the silent defaulting the onramp refuses. `ngnPerCoin` for + // USDC *is* naira-per-dollar (a USDC is a dollar, and `usdPerCoin` returns 1 + // without looking at a book), so a coin-less caller gets back exactly the + // number it gets today — correct for USDC, wrong for XLM, and already wrong + // in both clients. Refusing does not make an old APK price XLM correctly; it + // only stops it pricing at all. + // + // So this is a staged removal, not a decision to keep the default: + // 1. now - unknown coin 400s; missing coin answers as USDC and logs. + // 2. next - both clients send `coin`, and the availability check stops + // borrowing this endpoint ("is the service up" and "what is the + // rate for XLM" are different questions). + // 3. then - once an APK carrying step 2 is actually out and + // `logCoinlessRateRequest` has gone quiet, a missing coin 400s and + // this comment becomes the onramp's. + const COINS: Record = { xlm: "xlm", usdc: "usdc" }; + // The rate is computed by ngnPerCoin (shared with the onramp), which - // multiplies the NGN/USD rate by the coin's USD price so both rails quote + // multiplies the NGN/USD rate by the coin's USD price, so both rails quote // from one source. // // Per-coin cache: every cash-out screen asks for the rate when it opens, and @@ -130,16 +181,27 @@ export function createOfframpRouter( // indicative (the binding one is locked into the order), so a few seconds of // reuse costs nothing. Coins are cached separately so an XLM quote does not // evict a USDC quote that arrived a moment before. - const COINS: Record = { xlm: "xlm", usdc: "usdc" }; const RATE_CACHE_MS = 20_000; const rateCacheByCoins = new Map(); router.get("/rate", async (req: Request, res: Response) => { - const resolvedCoin = COINS[String(req.query["coin"] ?? "").toLowerCase()]; + // An absent `coin` is a build too old to send one, so it is answered the way + // it already reads the answer. A `coin` that is present but does not resolve + // — `doge`, an empty value left behind by a client that lost the variable, a + // repeated parameter — is a caller bug, and a plausible number for the wrong + // asset is the one outcome this endpoint exists to refuse. Values are + // lowercased and trimmed, so `XLM` is not read as unknown. + const supplied = req.query["coin"]; + const resolvedCoin = + supplied === undefined + ? LEGACY_DEFAULT_COIN + : COINS[String(supplied).trim().toLowerCase()]; + if (!resolvedCoin) { res.status(400).json({ error: `coin must be one of: ${Object.keys(COINS).join(", ")}` }); return; } + if (supplied === undefined) logCoinlessRateRequest(req); const cached = rateCacheByCoins.get(resolvedCoin); if (cached && Date.now() - cached.at < RATE_CACHE_MS) { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 027a707f..bfd7f1a8 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -637,16 +637,24 @@ export const linqWebhookAckResponseSchema = z.object({ export const offrampRateQuerySchema = z .object({ - /** Which asset to price. Required — defaulting silently to the dollar rate was the bug. */ - coin: z.enum(["xlm", "usdc"]).openapi({ - description: "Asset to price. Required: omitting it would silently return a dollar rate.", + /** + * Which asset to price. Omitted is served as `usdc` and logged — see the + * rollout note in `api/offramp.ts`; omitting it will 400 once the installed + * clients send a coin. + */ + coin: z.enum(["xlm", "usdc"]).optional().openapi({ + description: + "Asset to price. Send it: without one the answer is priced for `usdc`, which is naira per dollar and wrong for every other asset by that asset's price. Omitting it is deprecated and will be refused once the installed wallet builds ask for a coin; a response always names the coin it was priced for.", example: "usdc", }), }) .passthrough(); export const offrampRateResponseSchema = z.object({ - coin: z.enum(["xlm", "usdc"]), + coin: z.enum(["xlm", "usdc"]).openapi({ + description: + "The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`.", + }), rate: z.number().openapi({ description: "Naira per one unit of the requested coin, not naira per dollar.", }), From f53264dc3eb7f6edb947c8393abd463812436954 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 14:27:40 +0100 Subject: [PATCH 4/9] feat(offramp): add GET /offramp/availability so the cash-out gate stops borrowing the rate endpoint Stage 2's client change needs somewhere to ask "is cash-out up" that is not "what is the rate for XLM". Until now the only way a client could find out was /offramp/rate, and it read any error there as no-cash-out - which is why the coin requirement had to be staged rather than enforced, and why a coin-less request is the most reliable availability probe an old build has. The new route answers from process config only and makes no provider call, so an app opening on every install does not spend the rate allowance all users share. It returns a body as well as a status because a redirect, a proxy error page, or a captive portal is a reply, and a client that treats "got a reply" as "available" offers cash-out and then fails on the first tap. - src/api/offramp.ts: GET /availability -> { available: true }, no-store. The router's existing 503 when LINQ_API_KEY is unset is the negative answer, so nothing about deployment config is duplicated here. - src/openapi/schemas.ts, build.ts: the response schema and the registration. No 502 documented, because nothing here reaches the provider; 503 is documented because it is the answer a CTA is hidden on. - src/__tests__/routes/offramp.test.ts: answers without touching the price path, no-store, and 503 without a key. - docs/offramp-orders.md: the gate's contract, and stage 2 written out as the client repos have to satisfy it - including that a build holding a cached unavailable flag from the old rate-based probe stays wrong until that key is cleared. - openapi.json, clients/react-query/src/schema.d.ts: hand-written again, same caveat as the previous commit - regenerate before merge. --- clients/react-query/src/schema.d.ts | 86 ++++++++++++++++++++++ docs/offramp-orders.md | 53 ++++++++++++-- openapi.json | 102 +++++++++++++++++++++++++++ src/__tests__/routes/offramp.test.ts | 38 ++++++++++ src/api/offramp.ts | 35 ++++++++- src/openapi/build.ts | 21 ++++++ src/openapi/schemas.ts | 9 +++ 7 files changed, 337 insertions(+), 7 deletions(-) diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index 9d335f2f..d3b551bf 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -2452,6 +2452,92 @@ export interface paths { patch?: never; trace?: never; }; + "/offramp/availability": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Is cash-out enabled on this deployment + * @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). Answers the availability question without pricing anything: it makes no provider call, so it costs none of the shared rate allowance. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {boolean} */ + available: 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 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/rate": { parameters: { query?: never; diff --git a/docs/offramp-orders.md b/docs/offramp-orders.md index 7f787b9c..923458d7 100644 --- a/docs/offramp-orders.md +++ b/docs/offramp-orders.md @@ -44,6 +44,31 @@ a **new** `accessToken`; the previous token stops working. A different `walletAddress` gets `409` and nothing about the order. Idempotency keys should therefore be random and generated by the client. +## GET /offramp/availability — the gate + +``` +GET /offramp/availability → 200 { available: true } cash-out is enabled here + 503 { error: … } this deployment has no payout key +``` + +This is what a client should call when it wants to know whether to show a +cash-out CTA. It makes **no provider call**, so it spends none of the rate +allowance every user shares, and it never returns a number a client might mistake +for a price. + +It exists because there was no other way to ask. `isOfframpAvailable()` in the +mobile client called `/offramp/rate` and read any error as "cash-out is down", +which meant a pricing endpoint was the health check for the feature — and any +change to the rate contract, including the staged `coin` requirement below, was +an outage for every build that gated on it. + +Gate on the body, not only the status: a proxy error page, a redirect, or a +captive portal can all arrive as a response, and a client that treats "got a +reply" as "available" will offer cash-out and then fail on the first tap. + +The answer is served with `Cache-Control: no-store`, so a 200 cached before a +rollback cannot keep offering a tile whose every other route now 503s. + ## GET /offramp/rate — `coin`, and how it becomes required ``` @@ -82,10 +107,11 @@ for so a client can see the difference. 1. **Here.** An unknown coin 400s — new behaviour, nothing depends on it. A missing coin is answered as `usdc` and logged as deprecated, once per User-Agent, so the log reads as a list of callers rather than a flood. -2. **Clients.** Both wallet builds send `coin` on every rate call, including the - availability check — which should probe with an explicit coin, or better a - health route, because "is the service up" and "what is the rate for XLM" are - different questions and one endpoint should not be forced to answer both. +2. **Clients.** Both wallet builds send `coin` on every rate call, and the + availability check moves to [`GET /offramp/availability`](#get-offrampavailability--the-gate) + instead of borrowing the rate endpoint. The client work lives in the wallet + repos, not here, and it is the half that actually fixes XLM: the server cannot + update an installed APK. 3. **Then.** When a build carrying stage 2 is actually released **and** the deprecation line has been quiet for a full release cycle, a missing coin 400s and `logCoinlessRateRequest` is deleted along with the compatibility default. @@ -94,6 +120,25 @@ Stage 3 is a breaking change to a money-moving endpoint. Do not make it from the server side alone: it needs evidence from the log, not a judgement that enough time has passed. +### Stage 2, in the wallet repos + +The call sites were given in review — two in the web wallet's `lib/offramp.ts` +and two in mobile's — and the change at each is small: + +- `call('/offramp/rate')` becomes a call that sends `coin`, set to the asset that + screen is actually cashing out, not a constant. +- `isOfframpAvailable()` moves to `/offramp/availability`, and treats a non-200 + **or** a body without `available: true` as unavailable. It keeps its cached + flag — and a build still holding a cached `0` from the old rate-based probe is + wrong until that key is cleared, so bump or invalidate it in the upgrade. +- A screen showing a quote should read `coin` off the rate response instead of + assuming its request was honoured. That field is the only signal a build has + that it is on the deprecated path. + +Neither client repo is in this workspace, so this is written as the contract the +change has to satisfy, not as a patch. A build that ships only the availability +half stays compatible with stage 1 — that is the point of staging it this way. + ## Upgrading - Orders created before this change have no `id` or token and cannot be read diff --git a/openapi.json b/openapi.json index 8c939498..e2754f71 100644 --- a/openapi.json +++ b/openapi.json @@ -5112,6 +5112,108 @@ } } }, + "/offramp/availability": { + "get": { + "summary": "Is cash-out enabled on this deployment", + "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). Answers the availability question without pricing anything: it makes no provider call, so it costs none of the shared rate allowance.", + "x-internal": true, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "available" + ] + } + } + } + }, + "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" + ] + } + } + } + }, + "503": { + "description": "Offramp not configured on this deployment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/offramp/rate": { "get": { "summary": "Indicative naira-per-coin rate", diff --git a/src/__tests__/routes/offramp.test.ts b/src/__tests__/routes/offramp.test.ts index ec2d4345..fc81ad43 100644 --- a/src/__tests__/routes/offramp.test.ts +++ b/src/__tests__/routes/offramp.test.ts @@ -595,3 +595,41 @@ describe("GET /offramp/rate — naira per coin, not naira per dollar", () => { expect(res.status).toBe(502); }); }); + +/** + * The endpoint `isOfframpAvailable()` is meant to call instead of the rate one. + * What makes it usable as a gate is that it cannot price anything: a check that + * runs every time an app opens must not spend the provider allowance that every + * rate read shares. + */ +describe("GET /offramp/availability", () => { + beforeEach(() => { + // Call history is process-wide across describes in this file, and this test + // is about an absence of calls. + mockNgnPerCoin.mockClear(); + }); + + it("answers without touching the price path", async () => { + const res = await request(makeApp()).get("/offramp/availability"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ available: true }); + expect(mockNgnPerCoin).not.toHaveBeenCalled(); + }); + + it("is never cached, because a stale 200 keeps offering a tile that 503s", async () => { + const res = await request(makeApp()).get("/offramp/availability"); + expect(res.headers["cache-control"]).toBe("no-store"); + }); + + it("says so when this deployment has no payout key", async () => { + const saved = process.env.LINQ_API_KEY; + delete process.env.LINQ_API_KEY; + try { + const res = await request(makeApp()).get("/offramp/availability"); + expect(res.status).toBe(503); + expect(res.body.error).toMatch(/not configured/i); + } finally { + process.env.LINQ_API_KEY = saved; + } + }); +}); diff --git a/src/api/offramp.ts b/src/api/offramp.ts index 156d51a7..b04523a5 100644 --- a/src/api/offramp.ts +++ b/src/api/offramp.ts @@ -144,6 +144,35 @@ export function createOfframpRouter( next(); }); + /** + * Is cash-out switched on in this deployment? + * + * This exists so the availability gate has an endpoint that answers the + * question it is actually asking. `isOfframpAvailable()` used to call + * `/offramp/rate` and read any error as "no cash-out", which made a pricing + * endpoint the health check for a feature — so a change to the rate contract + * was an outage for every build that gated on it, and a coin-less rate request + * was the most reliable way to ask "is the service up". + * + * It deliberately does not contact the provider. Every rate read spends from + * an allowance all users share, and a gate that runs when an app opens — on + * every install, several times a day — is the worst possible place to spend + * it. "Is the provider reachable right now" is not this question, and cannot + * be answered cheaply: creating an order is what discovers that, and it + * reports the failure itself. + * + * The `available: true` body is not decoration. A client that only looked at + * the status would treat a redirect, an HTML error page from a proxy, or a + * captive portal as cash-out being up; a body it has to parse does not. + */ + router.get("/availability", (_req: Request, res: Response) => { + // no-store: this is the answer a client gates a CTA on, and a cached 200 + // from before a rollback would keep offering cash-out that 503s on every + // other route. + res.setHeader("Cache-Control", "no-store"); + res.json({ available: true }); + }); + // `coin` is the answer's subject, so the response always names it — that is // what lets a client tell which asset it was quoted for instead of assuming. // @@ -164,9 +193,9 @@ export function createOfframpRouter( // // So this is a staged removal, not a decision to keep the default: // 1. now - unknown coin 400s; missing coin answers as USDC and logs. - // 2. next - both clients send `coin`, and the availability check stops - // borrowing this endpoint ("is the service up" and "what is the - // rate for XLM" are different questions). + // 2. next - both clients send `coin`, and the availability check moves to + // `GET /offramp/availability` above instead of borrowing this + // endpoint. Stage 3 is what those clients unblock. // 3. then - once an APK carrying step 2 is actually out and // `logCoinlessRateRequest` has gone quiet, a missing coin 400s and // this comment becomes the onramp's. diff --git a/src/openapi/build.ts b/src/openapi/build.ts index b1f448c0..11b3ebcc 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -40,6 +40,7 @@ import { transferExportQuerySchema, binaryExportResponseSchema, linqWebhookAckResponseSchema, + offrampAvailabilityResponseSchema, offrampRateQuerySchema, offrampRateResponseSchema, offrampVerifyBankBodySchema, @@ -463,6 +464,26 @@ const internalErrorResponses = { 503: { description: "Offramp not configured on this deployment", content: { "application/json": { schema: offrampErrorResponseSchema } } }, }; +registry.registerPath({ + method: "get", + path: "/offramp/availability", + summary: "Is cash-out enabled on this deployment", + description: + internalDescription + + " Answers the availability question without pricing anything: it makes no provider call, so it costs none of the shared rate allowance.", + "x-internal": true, + responses: { + 200: { + description: "OK", + content: { "application/json": { schema: offrampAvailabilityResponseSchema } }, + }, + ...commonErrorResponses, + // No 502: nothing here reaches the provider. 503 is the answer a client + // hides its cash-out CTA on, so it is the one that has to be documented. + 503: { description: "Offramp not configured on this deployment", content: { "application/json": { schema: offrampErrorResponseSchema } } }, + }, +}); + registry.registerPath({ method: "get", path: "/offramp/rate", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index bfd7f1a8..f44e21e5 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -635,6 +635,15 @@ export const linqWebhookAckResponseSchema = z.object({ // ─── /offramp/* — cash-out surface (internal) ─────────────────────────────── +/** + * `available` is always true on a 200; the answer a client gates on is the + * status. The body exists so a redirect or a proxy's error page cannot be read + * as cash-out being up by a caller that only looked at the status code. + */ +export const offrampAvailabilityResponseSchema = z.object({ + available: z.literal(true), +}); + export const offrampRateQuerySchema = z .object({ /** From c23c990bb6a6adcc68abb39c50f93b8583de1923 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 14:55:09 +0100 Subject: [PATCH 5/9] docs(offramp): stage 2 is a pull request now, and two of its notes were wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing the client half found two claims in the rollout doc that the code does not support. The cached availability flag needed no migration. A build that cached `0` while the rate call was 400ing stores "the rails did not answer", and every launch overwrites it with a live probe — so the stale value self-heals rather than needing a key bump. And the coin each screen sends is a per-screen constant, because both cash-out screens are USDC-only today. The rule was never "not a constant", it was "the name sits where the asset is known" — a lib that hard-coded `usdc` on the caller's behalf is what made the pricing bug invisible. --- docs/offramp-orders.md | 40 +++++++++++++++++++++++++--------------- src/api/offramp.ts | 2 +- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/offramp-orders.md b/docs/offramp-orders.md index 923458d7..46d6b2c6 100644 --- a/docs/offramp-orders.md +++ b/docs/offramp-orders.md @@ -122,22 +122,32 @@ time has passed. ### Stage 2, in the wallet repos -The call sites were given in review — two in the web wallet's `lib/offramp.ts` -and two in mobile's — and the change at each is small: - -- `call('/offramp/rate')` becomes a call that sends `coin`, set to the asset that - screen is actually cashing out, not a constant. +Written up as the contract first; the clients now live in +[`Miracle656/veil`](https://github.com/Miracle656/veil), and stage 2 is +[veil#1124](https://github.com/Miracle656/veil/pull/1124). Four call sites, one +rate and one availability probe per client: + +- `call('/offramp/rate')` becomes a call that sends `coin`. Each cash-out screen + names the asset it moves — both of them are USDC-only today, so a per-screen + constant is right, as long as the constant says which asset and sits on the + screen that knows. A `lib` that hard-codes `usdc` on the caller's behalf is + what made this invisible in the first place. - `isOfframpAvailable()` moves to `/offramp/availability`, and treats a non-200 - **or** a body without `available: true` as unavailable. It keeps its cached - flag — and a build still holding a cached `0` from the old rate-based probe is - wrong until that key is cleared, so bump or invalidate it in the upgrade. -- A screen showing a quote should read `coin` off the rate response instead of - assuming its request was honoured. That field is the only signal a build has - that it is on the deprecated path. - -Neither client repo is in this workspace, so this is written as the contract the -change has to satisfy, not as a patch. A build that ships only the availability -half stays compatible with stage 1 — that is the point of staging it this way. + **or** a body without `available: true` as unavailable. A 200 from an edge + proxy parses to `{}`, and an unread "yes" offers a flow that fails on the + first tap. +- The cached availability flag keeps its key. A build holding a cached `0` from + the rate-based probe needs no migration: the stored meaning is unchanged ("the + rails answered"), and every launch runs a live probe that overwrites it. This + was first written as needing a bump, which was wrong. +- A screen showing a quote reads `coin` back off the response instead of + assuming its request was honoured, and refuses a quote for anything else. + That field is the only signal a build has that it is on the deprecated path — + and `ngn / rate` decides how much asset a user sends, so a USDC number + answering to `xlm` is not a rounding error. + +A build that ships only the availability half stays compatible with stage 1 — +that is the point of staging it this way. ## Upgrading diff --git a/src/api/offramp.ts b/src/api/offramp.ts index b04523a5..94d54dad 100644 --- a/src/api/offramp.ts +++ b/src/api/offramp.ts @@ -195,7 +195,7 @@ export function createOfframpRouter( // 1. now - unknown coin 400s; missing coin answers as USDC and logs. // 2. next - both clients send `coin`, and the availability check moves to // `GET /offramp/availability` above instead of borrowing this - // endpoint. Stage 3 is what those clients unblock. + // endpoint. veil#1124. Stage 3 is what those clients unblock. // 3. then - once an APK carrying step 2 is actually out and // `logCoinlessRateRequest` has gone quiet, a missing coin 400s and // this comment becomes the onramp's. From 2f82272f2c2640910922605fd07a8d3d3f3370d9 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 15:54:59 +0100 Subject: [PATCH 6/9] test(integration): the list cases still read the shape from before #218 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four failures, one cause. #218 stopped running a COUNT for every list query and made `total` opt-in through `includeTotal=true`, which is the right trade for an endpoint on the hot path — but it updated the unit tests and the README and left these three files asking `body.total` of a response that no longer computes one, so the integration job has been red since. The three cases that assert an exact count now ask for it, which is also what tests the flag end to end. The one case in `e2e.test.ts` that only wanted `total >= 1` does not need a COUNT at all: the two lines after it prove a specific row came back, which is a stronger claim about the same response. The incoming case gained the other half — without the flag, no `total`, and `hasMore` says the page is complete. `src/api.ts` said `{ total, limit, offset, transfers }` in three route docstrings, which is the sentence that kept leading readers to the old shape. --- src/api.ts | 9 ++++++--- tests/integration/api.test.ts | 17 ++++++++++++++--- tests/integration/e2e.test.ts | 5 ++++- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/api.ts b/src/api.ts index e1ff0568..602fb5c8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -541,9 +541,10 @@ export function createApp(): express.Application { * toLedger — inclusive upper bound * limit — page size (max 200, default 50) * offset — pagination offset (default 0) + * includeTotal— also return `total`, which costs a full COUNT (#206) * * Response: - * { total, limit, offset, transfers: [...] } + * { total?, hasMore, nextCursor, limit, offset, transfers: [...] } */ app.get( "/transfers/incoming/:address", @@ -682,9 +683,10 @@ export function createApp(): express.Application { * toLedger — inclusive upper bound * limit — page size (max 200, default 50) * offset — pagination offset (default 0) + * includeTotal— also return `total`, which costs a full COUNT (#206) * * Response: - * { total, limit, offset, transfers: [{ ...fields, direction }] } + * { total?, hasMore, nextCursor, limit, offset, transfers: [{ ...fields, direction }] } */ app.get( "/transfers/address/:address", @@ -989,9 +991,10 @@ export function createApp(): express.Application { * toLedger — inclusive upper ledger bound * limit — page size (max 200, default 50) * offset — pagination offset (default 0) + * includeTotal— also return `total`, which costs a full COUNT (#206) * * Response: - * { total, limit, offset, transfers: [...] } + * { total?, hasMore, nextCursor, limit, offset, transfers: [...] } */ app.get( "/nfts/transfers", diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index 3396a883..531c628e 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -18,9 +18,11 @@ async function getJson(path: string): Promise> { } describe("Wraith integration API", () => { + // `total` is opt-in since #218 — it costs a second full pass over the matching + // rows — so a case that asserts a count has to ask for one. it("returns filtered incoming transfers from Postgres", async () => { const body = await getJson( - `/transfers/incoming/${ALICE}?contractId=${CONTRACT_A}&eventType=transfer,mint` + `/transfers/incoming/${ALICE}?contractId=${CONTRACT_A}&eventType=transfer,mint&includeTotal=true` ); expect(body.total).toBe(2); @@ -33,10 +35,19 @@ describe("Wraith integration API", () => { toAddress: ALICE, displayAmount: "2.5000000", }); + + // And the answer a caller gets without asking for a count: the page and the + // fact that it is a complete page, with no COUNT run anywhere. + const uncounted = await getJson( + `/transfers/incoming/${ALICE}?contractId=${CONTRACT_A}&eventType=transfer,mint` + ); + expect(uncounted.total).toBeUndefined(); + expect(uncounted.hasMore).toBe(false); + expect(uncounted.transfers).toHaveLength(2); }); it("returns combined account history with directions and pagination", async () => { - const body = await getJson(`/transfers/address/${ALICE}?limit=3&offset=1`); + const body = await getJson(`/transfers/address/${ALICE}?limit=3&offset=1&includeTotal=true`); expect(body.total).toBe(5); expect(body.limit).toBe(3); @@ -91,7 +102,7 @@ describe("Wraith integration API", () => { }); it("excludes unrelated account activity from account-specific results", async () => { - const body = await getJson(`/transfers/outgoing/${BOB}`); + const body = await getJson(`/transfers/outgoing/${BOB}?includeTotal=true`); expect(body.total).toBe(2); expect(body.transfers.map((transfer: { toAddress: string }) => transfer.toAddress)).toEqual([ diff --git a/tests/integration/e2e.test.ts b/tests/integration/e2e.test.ts index 77ed69d0..1b7ba65f 100644 --- a/tests/integration/e2e.test.ts +++ b/tests/integration/e2e.test.ts @@ -60,7 +60,10 @@ describeE2E("E2E: ingest → query (#74)", () => { .query({ contractId: fixture.contractId }); expect(res.status).toBe(200); - expect(res.body.total).toBeGreaterThanOrEqual(1); + // The page, not a count: `total` is opt-in since #218 because it costs a + // full pass over the matching rows, and the row assertions below say more + // than `total >= 1` did anyway. + expect(res.body.transfers.length).toBeGreaterThanOrEqual(1); const row = (res.body.transfers as Array>).find( t => t["eventId"] === fixture.eventId, From c5b5b3610f227e9878d09ee8bdcdcf7686900b8d Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 15:55:40 +0100 Subject: [PATCH 7/9] fix(ci): the dual-network harness never told /status it runs two chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "reports both networks with differing cursors on /status" got a `networks` object with `testnet` in it and no `mainnet`. `/status` lists the loops in this process and, finding none it can see, falls back to `enabledNetworks()` — which with `NETWORKS` unset is `currentNetwork()`, and with `STELLAR_NETWORK` unset that is testnet. The single key the response had was the fallback answering, not the registry. The harness registers its loops through `_createLoopForTesting` and the DB assertions around it pass, so why the route did not see them through the module-level registry is still unanswered; what is certain is that the assertion should not depend on which of the two paths answered. Setting `NETWORKS=testnet,mainnet` makes the harness describe the deployment it is simulating, and then both routes to the answer agree. It also matches what a real dual-chain process looks like: `NETWORKS` is what an operator sets, and `?network=mainnet` is rejected by the same list, so a test that wants mainnet reported has to declare mainnet enabled. --- tests/integration/dual-network.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration/dual-network.test.ts b/tests/integration/dual-network.test.ts index ae001972..ad574441 100644 --- a/tests/integration/dual-network.test.ts +++ b/tests/integration/dual-network.test.ts @@ -206,6 +206,11 @@ describeDual("Dual-network correctness harness (#172)", () => { // Fast-fail RPC so stray metadata/SAC lookups never hang on live network. process.env.SOROBAN_RPC_URL_TESTNET = "http://127.0.0.1:1"; process.env.SOROBAN_RPC_URL_MAINNET = "http://127.0.0.1:1"; + // The scenario is a process indexing both chains, so say so. `/status` + // lists the loops it is running and otherwise falls back to `NETWORKS`; + // leaving that unset made the fallback answer `testnet` alone, because an + // unset `STELLAR_NETWORK` defaults to it. + process.env.NETWORKS = "testnet,mainnet"; _resetRpcClients(); _resetIndexerLoops(); _resetTokenCache(); From 81293b6a715fcc8066966227c8eea868ac65a887 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 16:31:42 +0100 Subject: [PATCH 8/9] fix(clients): regenerate the React Query schema that openapi.json moved on without MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK workflow fails when src/schema.d.ts differs from what `npm run generate` reads out of openapi.json, and the committed copy had fallen behind three routes: /ngn/customers/status and /fees were never carried over, and /status gained the per-network object plus nullable lag fields when the indexer stopped being a single loop. So the gap reads as belonging to the offramp change it happens to sit next to. It does not — the check has been red on main since the commit that documented /fees, and this branch only inherits it by touching openapi.json, which is the path filter that wakes the workflow up. The one genuinely new line is the @enum on the rate reply's coin. The file is generated, so the diff is the generator's own output rather than a hand-written guess at it: no npm on this machine, so it came from the log of the run that just failed, applied as a patch and checked with git apply before it was staged. --- clients/react-query/src/schema.d.ts | 175 +++++++++++++++++++++++++++- 1 file changed, 169 insertions(+), 6 deletions(-) diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index d3b551bf..000d159e 100644 --- a/clients/react-query/src/schema.d.ts +++ b/clients/react-query/src/schema.d.ts @@ -202,6 +202,86 @@ export interface paths { patch?: never; trace?: never; }; + "/ngn/customers/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Is this customer reference verified? + * @description Read-only check against the naira provider. Creating nothing is the point: the only previous way to ask was to provision a customer and read `verified` off the reply, which needed a full set of personal details to answer a yes/no question about a reference the caller already held. Rate-limited, because a reference is derived from a wallet address and an unthrottled answer would be an oracle. + */ + get: { + parameters: { + query: { + /** @description Our identifier for the person, stable for the life of their install. Derived from the wallet address, never a provider id. */ + customerRef: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + customerRef: string; + /** @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. */ + verified: boolean; + status?: 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; + }; "/metrics": { parameters: { query?: never; @@ -273,12 +353,25 @@ export interface paths { /** @enum {string} */ network?: "testnet" | "mainnet"; lastIndexedLedger: number | null; - latestLedger: number; - lagLedgers: number; + latestLedger: number | null; + lagLedgers: number | null; /** Format: date-time */ - startedAt: string; - uptimeSeconds: number; - totalIndexed: number; + startedAt?: string; + uptimeSeconds?: number; + totalIndexed?: number; + networks?: { + [key: string]: { + lastIndexedLedger: number | null; + latestLedger: number | null; + lagLedgers: number | null; + running: boolean; + reason?: string; + } & { + [key: string]: unknown; + }; + }; + } & { + [key: string]: unknown; }; }; }; @@ -2212,6 +2305,73 @@ export interface paths { patch?: never; trace?: never; }; + "/fees": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: { + parameters: { + query?: { + /** @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. */ + amount?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @description False when no fee is configured, so a client can tell a deliberate waiver from a backend too old to know about fees. */ + enabled: boolean; + /** @description Keyed by asset code. Always charged in the asset being sent. */ + fees: { + [key: string]: { + /** + * @description What to charge, in whole units of the fee asset, as a decimal string. + * @example 0.25 + */ + amount: string; + /** @example USDC */ + assetCode: string; + /** @description Null for native XLM. */ + assetIssuer: string | null; + /** @description The classic account the fee is paid to. */ + destination: string; + /** @description Percent of the sent amount, when the fee scales. Absent when it is flat. */ + percent?: string; + /** @description Floor; present only alongside percent. */ + min?: string; + /** @description Cap; present only alongside percent. */ + max?: string; + }; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/transfers.csv": { parameters: { query?: never; @@ -2568,7 +2728,10 @@ export interface paths { }; content: { "application/json": { - /** @description The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`. */ + /** + * @description The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`. + * @enum {string} + */ coin: "xlm" | "usdc"; /** @description Naira per one unit of the requested coin, not naira per dollar. */ rate: number; From e746aa4db5ffb2e33b7feb8079192d43f4f485a1 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 16:36:27 +0100 Subject: [PATCH 9/9] fix(ci): give the unit step a deadline so a stuck jest reports instead of sulking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm test` used to finish in about 30 seconds, and did as recently as the run before this one. Every job since has sat on that step with nothing to show for it: the build job declared no timeout-minutes, so GitHub's six-hour default applies, and a job that is still running has no downloadable log. A hang ends up indistinguishable from CI being slow, both from outside and from in here. Two deadlines, one at each level. The step gets 15 minutes with a KILL 30 seconds behind it, because a jest left holding an open handle may not answer TERM, and a killed process flushes the output it already produced — which is the point: the log names the suite that stuck. The job gets 20 minutes so a step that ignores both still ends. This does not fix the hang and is not pretending to. It turns a six-hour non-answer into a fifteen-minute failure with a readable log, which is the shortest path to the handle itself. --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eab10a2..ef97d481 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: build: name: Typecheck & build runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -21,7 +22,14 @@ jobs: - run: npm run typecheck - run: npx tsc --noEmit - run: npm run build - - run: npm test --if-present + # The suite finishes in about half a minute. Anything still running a + # quarter of an hour later is stuck on an open handle, not on tests — and + # a stuck step with no deadline holds a runner for six hours while its + # log stays unreadable, which is how a hang gets mistaken for CI being + # slow. The timeout kills jest outright so the output collected up to + # that point is flushed and downloadable. + - name: Unit tests + run: timeout -k 30s 900s npm test --if-present integration: name: Integration tests