From 615d09aec79e1b260aeacd1727217dcd46cb35be Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Fri, 16 Oct 2026 01:06:21 +0100 Subject: [PATCH 01/16] 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 1d8ed5ef1..2f773a89d 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 289683856..5bad02bc6 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 8082d6d7f..d2c761d91 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 03363be74..c9e4a8597 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 6687e6ae3..a1a934a84 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 02/16] 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 f807dbbbb..967b8f431 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 5ddbe85c3..802e68171 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 60188e95b..89d9e712e 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 b233079a2..7db022ba0 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 7486c938c..b1f448c0c 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 6e7bdc178..027a707ff 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 03/16] 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 b86fd99f8..9d335f2ff 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 e012a54bc..7f787b9ce 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 967b8f431..8c9394985 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 802e68171..ec2d43451 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 7db022ba0..156d51a72 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 027a707ff..bfd7f1a8f 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 04/16] 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 9d335f2ff..d3b551bfe 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 7f787b9ce..923458d77 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 8c9394985..e2754f71b 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 ec2d43451..fc81ad43d 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 156d51a72..b04523a5f 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 b1f448c0c..11b3ebcc9 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 bfd7f1a8f..f44e21e51 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 05/16] 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 923458d77..46d6b2c65 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 b04523a5f..94d54dadd 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 06/16] 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 e1ff05683..602fb5c86 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 3396a883b..531c628e7 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 77ed69d07..1b7ba65f9 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 07/16] 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 ae0019729..ad574441e 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 08/16] 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 d3b551bfe..000d159e5 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 09/16] 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 4eab10a2e..ef97d4813 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 From 40a3b42d38ba39ede8224fa55602d4e59ff2d1d0 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:05:09 +0100 Subject: [PATCH 10/16] fix(indexer): stop a crashed loop from being the reason the process never exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's unit step hung for hours on a suite that finishes in 22 seconds. The deadline added in the previous commit turned that into a readable log, and the log said: `Cannot log after tests are done`, once every 10 seconds, with `[indexer/testnet] loop crashed, restarting in 10s` between each. A case calls startAllIndexers to check which networks it would run, gets its answer, and leaves behind a loop that crashes on the absent database, schedules a restart, crashes again, and schedules another. That chain is what jest waits on, so the run never ends and never reports. It is not this branch's case and not this branch's timer — the call came in with the per-network status work on main, and every branch that merges with it inherits the hang, which is why main has been red since the morning too. The restart wait is unref'd, along with the pause between two polls. Neither is a reason to keep a process alive; the API listening is, and `main` always starts one. Held to the event loop they are immortal in either direction: a loop that keeps crashing restarts forever, and a healthy one polls forever. `_resetIndexerLoops` now cancels pending restarts as well as dropping loop state, so the seam that already exists for tidying between cases tidies the timer too. That alone does not fix the run above — the restart is scheduled after the last case ends, so there is no next beforeEach to catch it — which is why the unref is the actual fix and this is the hygiene that goes with it. --- src/indexer.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/indexer.ts b/src/indexer.ts index c9e4a8597..3bec84bea 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -211,8 +211,17 @@ export function runningNetworks(): Network[] { return [...loops.keys()]; } -/** Test-only: drops loop state between cases. */ +/** + * Restarts scheduled after a loop crashed, kept so a case that started a loop + * can cancel it again. Without this the chain outlives whoever called + * `startAllIndexers` — see `_resetIndexerLoops`. + */ +const restartTimers = new Set(); + +/** Test-only: drops loop state and cancels pending restarts between cases. */ export function _resetIndexerLoops(): void { + for (const timer of restartTimers) clearTimeout(timer); + restartTimers.clear(); loops.clear(); } @@ -568,7 +577,16 @@ export function startAllIndexers(networks: Network[] = enabledNetworks()): Netwo const run = (net: Network) => { startIndexer(net).catch((err) => { console.error(`[indexer/${net}] loop crashed, restarting in 10s:`, err); - setTimeout(() => run(net), 10_000); + const timer = setTimeout(() => { + restartTimers.delete(timer); + run(net); + }, 10_000); + restartTimers.add(timer); + // Unref'd, because a restart wait is not a reason to keep a process + // alive — the API listening is. Held to the event loop, this chain is + // immortal: the first crashed loop reschedules itself forever, and + // anything that imported the module can never finish, let alone exit. + timer.unref(); }); }; @@ -577,5 +595,10 @@ export function startAllIndexers(networks: Network[] = enabledNetworks()): Netwo } function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); + return new Promise((resolve) => { + // Same reason as the restart timer above: the gap between two polls must + // not be what keeps the process running. + const timer = setTimeout(resolve, ms); + timer.unref(); + }); } From 3dd569109c55c928057fd83728e54b669cc7cd6d Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:18:53 +0100 Subject: [PATCH 11/16] ci: take the step deadline back out of this pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timeout did exactly what it was added to do: it ended a six-hour non-answer in fifteen minutes and produced the log that named the crashed indexer loop in the commit before this one. It has no further work here, and keeping it costs more than it is worth inside a pull request about naira rates. A pull request that changes a workflow file holds every later commit from the same author for a collaborator to approve. That is the right rule — a fork should not get to run its own edited CI unchecked — but it means the actual fix in this branch sits unverified until someone clicks, for a reason that has nothing to do with offramp rates. The deadline goes to its own pull request, where it can be approved for what it is. --- .github/workflows/ci.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef97d4813..4eab10a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,6 @@ jobs: build: name: Typecheck & build runs-on: ubuntu-latest - timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -22,14 +21,7 @@ jobs: - run: npm run typecheck - run: npx tsc --noEmit - run: npm run build - # 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 + - run: npm test --if-present integration: name: Integration tests From 5ab2b0189e65354b06c6103a2a01ecedcc28387e Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:31:52 +0100 Subject: [PATCH 12/16] fix(indexer): put back the restart timer set that merging main ate Merging main into this branch kept both halves of `_resetIndexerLoops` but dropped the `restartTimers` declaration it iterates, so nothing compiled: typecheck, the Docker build behind the integration suite and the chaos image all failed on the same four TS2304 errors. Co-authored-by: Qoder --- src/indexer.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/indexer.ts b/src/indexer.ts index cb7cfae7a..5d415bb4d 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -234,6 +234,13 @@ export function setIndexerUnstarted(network: Network, reason: string): void { unstartedNetworks.set(network, reason); } +/** + * Restarts scheduled after a loop crashed, kept so a case that started a loop + * can cancel it again. Without this the chain outlives whoever called + * `startAllIndexers` — see `_resetIndexerLoops`. + */ +const restartTimers = new Set(); + /** Test-only: drops loop state and unstarted records between cases. */ export function _resetIndexerLoops(): void { for (const timer of restartTimers) clearTimeout(timer); From e8314374b6d2daee44e5c1a54cab5579eb612ba9 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:39:07 +0100 Subject: [PATCH 13/16] fix(indexer): let a dropped registration end its loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 816 unit tests pass and Jest still exits 1, because the loop started by the #238 case is still polling when the environment goes away: its next RPC call lazily requires a module from a torn-down sandbox and throws after the summary has printed. Before the restart timer was unref'd the process never got that far — it hung instead — so this is the same abandoned loop, now visible. `while (true)` becomes `while (loops.get(net) === loop)`. `_resetIndexerLoops` already means "these loops are no longer wanted" to the callers that use it, so let the loop agree; the last case in a file now also stops its own loops in an `afterEach`, rather than leaving the next `beforeEach` to do it after Jest has already torn everything down. Co-authored-by: Qoder --- src/__tests__/multiNetworkIndexer.test.ts | 6 ++++++ src/indexer.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/__tests__/multiNetworkIndexer.test.ts b/src/__tests__/multiNetworkIndexer.test.ts index e1b204d1d..5e2996d06 100644 --- a/src/__tests__/multiNetworkIndexer.test.ts +++ b/src/__tests__/multiNetworkIndexer.test.ts @@ -46,6 +46,12 @@ beforeEach(() => { _resetIndexerLoops(); }); +// A case that calls `startAllIndexers` starts a real loop, and the next +// `beforeEach` is too late for the last case in the file: nothing runs after +// it, so that loop would still be polling while Jest tears the environment +// down. +afterEach(() => _resetIndexerLoops()); + describe("enabledNetworks", () => { it("defaults to the single configured network, so existing deployments are unchanged", () => { expect(enabledNetworks()).toEqual(["testnet"]); diff --git a/src/indexer.ts b/src/indexer.ts index 5d415bb4d..8a55fa4f8 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -519,7 +519,11 @@ export async function startIndexer(network?: Network): Promise { } // ── Polling loop ──────────────────────────────────────────────────────────── - while (true) { + // Runs while this loop is still the registered one. `_resetIndexerLoops` + // drops registrations between test cases, and a loop that outlives them + // would keep polling — and start a fresh RPC request — after the code that + // started it is gone. Production registers once and never clears. + while (loops.get(net) === loop) { try { const tip = await withRetry(() => loop.sourceSwitcher.getLatestLedger()); const target = tip - TIP_LAG; From 35d07d406ca3d0f706e0bc17fed29411288b3fcb Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:45:08 +0100 Subject: [PATCH 14/16] fix(indexer): let a dropped registration end its loop All 816 unit tests pass and Jest still exits 1, because the loop the #238 case starts outlives the suite: its pending work rejects once the mock database is gone, the crash handler announces "restarting in 10s" after the summary has printed, and Jest counts output after teardown as a failure. Unref'ing the restart timer (40a3b42) made the process exit; this is what it now prints on the way out. Silence is only correct if the loop was dropped on purpose, so the request to keep a network running is tracked separately from the loop's own state: `_resetIndexerLoops` drops it, and a rejection that arrives afterwards ends the loop instead of reporting and restarting it. In production nothing ever drops it, so a crashed loop still gets its restart and its log. Co-authored-by: Qoder --- src/indexer.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/indexer.ts b/src/indexer.ts index 8a55fa4f8..a27151dfe 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -241,10 +241,22 @@ export function setIndexerUnstarted(network: Network, reason: string): void { */ const restartTimers = new Set(); +/** + * Networks `startAllIndexers` was asked to keep running. + * + * A loop that rejects while its network is still in here crashed on its own + * and earns a restart. One that rejects after the set was dropped is a case + * tearing the database out from under it, and announcing "restarting in 10s" + * for a loop someone deliberately stopped would be a lie — with output + * arriving after the suite finished, which Jest counts as a failure. + */ +const requestedNetworks = new Set(); + /** Test-only: drops loop state and unstarted records between cases. */ export function _resetIndexerLoops(): void { for (const timer of restartTimers) clearTimeout(timer); restartTimers.clear(); + requestedNetworks.clear(); loops.clear(); unstartedNetworks.clear(); } @@ -547,6 +559,7 @@ export async function startIndexer(network?: Network): Promise { await maybeTombstoneExpiredContracts(loop, currentLedger); } catch (err) { + if (loops.get(net) !== loop) return; console.error(`[indexer/${net}] Unhandled error in poll loop:`, err); // Back off before retrying to avoid hammering the RPC on persistent errors await sleep(POLL_INTERVAL_MS * 2); @@ -605,6 +618,7 @@ export function startAllIndexers(networks: Network[] = enabledNetworks()): Netwo const run = (net: Network) => { startIndexer(net).catch((err) => { + if (!requestedNetworks.has(net)) return; console.error(`[indexer/${net}] loop crashed, restarting in 10s:`, err); const timer = setTimeout(() => { restartTimers.delete(timer); @@ -619,6 +633,7 @@ export function startAllIndexers(networks: Network[] = enabledNetworks()): Netwo }); }; + for (const net of runnable) requestedNetworks.add(net); for (const net of runnable) run(net); return runnable; } From 27d5dc66c22a06bf6f7a69f5be5accfe4458a775 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:51:13 +0100 Subject: [PATCH 15/16] refactor(indexer): split which networks can run from starting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silencing the dropped loop (35d07d4) still left Jest exiting 1 with 816 tests passing. The remaining output is the loop the #238 case starts: its startup awaits a database that does not exist in a unit run and a public testnet RPC, so every line it prints — and the retry it fires after the first one fails — lands after the suite has finished. Jest counts that as a failure, and a require from inside that retry is the ReferenceError the earlier runs showed. The case never wanted a loop. It asks which networks a half-configured set yields and why the other is skipped, so that question now has its own function and `startAllIndexers` is just the part that acts on it. A second case covers what #238 actually broke — a missing endpoint used to throw out of main() — and it can call the starter safely, because with nothing runnable it starts nothing. Co-authored-by: Qoder --- src/__tests__/multiNetworkIndexer.test.ts | 29 +++++++++--- src/indexer.ts | 55 ++++++++++++++--------- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/__tests__/multiNetworkIndexer.test.ts b/src/__tests__/multiNetworkIndexer.test.ts index 5e2996d06..6cb46e12f 100644 --- a/src/__tests__/multiNetworkIndexer.test.ts +++ b/src/__tests__/multiNetworkIndexer.test.ts @@ -15,6 +15,7 @@ import { resolveNftContractIds, resolveSacContractIds, runningNetworks, + planNetworks, startAllIndexers, isIndexerRunning, getIndexerUnstartedReason, @@ -227,19 +228,33 @@ describe("loop state isolation", () => { it("handles unconfigured RPC path gracefully and tracks unstarted status (#238)", () => { // testnet has public default; mainnet has no SOROBAN_RPC_URL_MAINNET const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); - const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {}); try { - // Pass both networks where mainnet is missing its RPC endpoint - const runnable = startAllIndexers(["testnet", "mainnet"]); + // Pass both networks where mainnet is missing its RPC endpoint. + // `planNetworks`, not `startAllIndexers`: the case asks which loops the + // configuration yields, and calling the starter would leave a real + // testnet loop polling RPC long after this case has returned. + const runnable = planNetworks(["testnet", "mainnet"]); - expect(runnable).toContain("testnet"); - expect(runnable).not.toContain("mainnet"); - expect(isIndexerRunning("mainnet")).toBe(false); + expect(runnable).toEqual(["testnet"]); expect(getIndexerUnstartedReason("mainnet")).toMatch(/SOROBAN_RPC_URL_MAINNET is required/); } finally { consoleError.mockRestore(); - consoleLog.mockRestore(); + } + }); + + it("starts nothing and throws nothing when no network is configured (#238)", () => { + // The original failure: one missing endpoint killed the process during + // main(), taking the API down with it. + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + try { + expect(() => startAllIndexers(["mainnet"])).not.toThrow(); + expect(startAllIndexers(["mainnet"])).toEqual([]); + expect(isIndexerRunning("mainnet")).toBe(false); + expect(runningNetworks()).toEqual([]); + } finally { + consoleError.mockRestore(); } }); }); diff --git a/src/indexer.ts b/src/indexer.ts index a27151dfe..580ed05bd 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -568,27 +568,28 @@ export async function startIndexer(network?: Network): Promise { } /** - * Start one loop per enabled network (see `NETWORKS`). + * Split the requested networks into the ones that can start and the ones that + * cannot, recording why for the latter. * - * Loops are fault-isolated from each other: a loop that throws out of its own - * retry handling is restarted on its own, because a mainnet RPC key expiring - * must not stop testnet indexing. `startIndexer` never resolves, so this - * returns immediately with the loops running in the background. + * Separate from starting them because this answer is what a caller acts on: + * `/status` reports the networks that were skipped, and a test can ask which + * loops *would* run without leaving a real one polling after the case that + * asked has finished. `startAllIndexers` starts what this returns. + * + * Validated per network, and a failure skips that network rather than throwing. + * + * `validateNetworkConfig(networks)` threw for the whole set if any one entry + * was misconfigured, which broke the isolation promised in + * `startAllIndexers` in the worst way: an unset SOROBAN_RPC_URL_MAINNET killed + * the process during main(), taking testnet indexing AND the HTTP API with it. + * The API does not index. The naira rails are plain HTTP to Linq and never + * touch Soroban RPC, so a missing mainnet endpoint was stopping /ngn from + * answering for a reason that has nothing to do with it. + * + * Serving reads on the networks that *are* configured, while saying loudly + * which one is not, beats refusing to serve anything. */ -export function startAllIndexers(networks: Network[] = enabledNetworks()): Network[] { - // Validated per network, and a failure skips that network rather than - // throwing. - // - // `validateNetworkConfig(networks)` threw for the whole set if any one entry - // was misconfigured, which broke the isolation promised above in the worst - // way: an unset SOROBAN_RPC_URL_MAINNET killed the process during main(), - // taking testnet indexing AND the HTTP API with it. The API does not index. - // The naira rails are plain HTTP to Linq and never touch Soroban RPC, so a - // missing mainnet endpoint was stopping /ngn from answering for a reason - // that has nothing to do with it. - // - // Serving reads on the networks that *are* configured, while saying loudly - // which one is not, beats refusing to serve anything. +export function planNetworks(networks: Network[] = enabledNetworks()): Network[] { const runnable: Network[] = []; for (const net of networks) { try { @@ -611,9 +612,23 @@ export function startAllIndexers(networks: Network[] = enabledNetworks()): Netwo "[indexer] No network is configured for indexing. The API is still serving; " + "nothing is being indexed.", ); - return []; } + return runnable; +} + +/** + * Start one loop per enabled network (see `NETWORKS`). + * + * Loops are fault-isolated from each other: a loop that throws out of its own + * retry handling is restarted on its own, because a mainnet RPC key expiring + * must not stop testnet indexing. `startIndexer` never resolves, so this + * returns immediately with the loops running in the background. + */ +export function startAllIndexers(networks: Network[] = enabledNetworks()): Network[] { + const runnable = planNetworks(networks); + if (runnable.length === 0) return []; + console.log(`[indexer] Starting loops for: ${runnable.join(", ")}`); const run = (net: Network) => { From 7bc5d071f487fce0acc14e801dcb0b1d60297fc0 Mon Sep 17 00:00:00 2001 From: Daveeee-tech Date: Sun, 18 Oct 2026 17:58:30 +0100 Subject: [PATCH 16/16] test(accounts): stop probing the live testnet RPC from a unit case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the suite reports instead of hanging it can be seen failing: the first balance case exceeds the 5s timeout. It is not the route. The stale-read middleware (#164) probes RPC health on every GET, and this was the one app-level suite that left `../../rpc` real, so the first request went out to the public testnet Soroban endpoint and waited for it — the rest of the file was fast because the answer is cached for 3s, which made it look like a one-off. The late `[api] Unhandled error: ... reading 'map'` in the same run is the tail of that request arriving after the case had already been abandoned. Co-authored-by: Qoder --- src/__tests__/routes/accounts.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/__tests__/routes/accounts.test.ts b/src/__tests__/routes/accounts.test.ts index 3491976e8..d18f95417 100644 --- a/src/__tests__/routes/accounts.test.ts +++ b/src/__tests__/routes/accounts.test.ts @@ -9,6 +9,17 @@ jest.mock("../../db", () => ({ prisma: { $queryRaw: jest.fn() }, })); +// The stale-read middleware probes RPC on every GET (#164), and this file was +// the one route suite that left `../../rpc` real: the first request paid for a +// live call to the public testnet Soroban endpoint, and when that took longer +// than the 5s test timeout the case failed for a reason that has nothing to do +// with balances. Later requests were fast because the health answer is cached +// for 3s, so it read as a one-off. +jest.mock("../../rpc", () => ({ + ...jest.requireActual("../../rpc"), + getLatestLedger: jest.fn().mockResolvedValue(1234), +})); + const mockQueryBalances = queryBalances as jest.MockedFunction; describe("Accounts route handlers", () => {