diff --git a/clients/react-query/src/schema.d.ts b/clients/react-query/src/schema.d.ts index b86fd99f8..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; @@ -2452,6 +2612,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; @@ -2460,12 +2706,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 +2728,13 @@ 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`. + * @enum {string} + */ + 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..46d6b2c65 100644 --- a/docs/offramp-orders.md +++ b/docs/offramp-orders.md @@ -44,6 +44,111 @@ 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 + +``` +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, 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. + +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 + +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. 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 - Orders created before this change have no `id` or token and cannot be read diff --git a/openapi.json b/openapi.json index ed1cb141d..2b2a4a7b3 100644 --- a/openapi.json +++ b/openapi.json @@ -5236,11 +5236,130 @@ } } }, + "/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 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. 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": 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" + } + ], "responses": { "200": { "description": "OK", @@ -5249,14 +5368,17 @@ "schema": { "type": "object", "properties": { - "rate": { - "type": "number" - }, - "currency": { - "type": "string" - }, "coin": { - "type": "string" + "type": "string", + "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", + "description": "Naira per one unit of the requested coin, not naira per dollar." }, "indicative": { "type": "boolean", @@ -5266,9 +5388,8 @@ } }, "required": [ - "rate", - "currency", "coin", + "rate", "indicative" ] } diff --git a/src/__tests__/multiNetworkIndexer.test.ts b/src/__tests__/multiNetworkIndexer.test.ts index e1b204d1d..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, @@ -46,6 +47,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"]); @@ -221,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/__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", () => { diff --git a/src/__tests__/routes/offramp.test.ts b/src/__tests__/routes/offramp.test.ts index 5ddbe85c3..fc81ad43d 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(), })); -import { createOfframpRouter } from "../../api/offramp"; +jest.mock("../../api/ngn", () => ({ + ngnPerCoin: jest.fn(), +})); + +import { createOfframpRouter, __resetCoinlessRateLog } 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,168 @@ 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. + // + // 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) + // 290 NGN per XLM (XLM ≈ $0.175 × 1,655) + 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 === undefined ? req : req.query({ coin }); + } + + 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("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 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); + 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); + }); +}); + +/** + * 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.ts b/src/api.ts index 438566146..d1bf57a6c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -557,9 +557,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", @@ -698,9 +699,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", @@ -1005,9 +1007,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/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..94d54dadd 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 @@ -93,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 { @@ -110,24 +144,107 @@ 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. + /** + * 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. + // + // 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 moves to + // `GET /offramp/availability` above instead of borrowing this + // 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. + 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 + // 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 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) => { + // 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()]; - router.get("/rate", async (_req: Request, res: Response) => { - if (rateCache && Date.now() - rateCache.at < RATE_CACHE_MS) { - res.json(rateCache.body); + 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) { + 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/indexer.ts b/src/indexer.ts index 38a541d31..580ed05bd 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -234,8 +234,29 @@ 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(); + +/** + * 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(); } @@ -510,7 +531,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; @@ -534,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); @@ -542,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 { @@ -585,22 +612,52 @@ 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) => { startIndexer(net).catch((err) => { + if (!requestedNetworks.has(net)) return; 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(); }); }; + for (const net of runnable) requestedNetworks.add(net); for (const net of runnable) run(net); return runnable; } 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(); + }); } diff --git a/src/openapi/build.ts b/src/openapi/build.ts index f9b6bea3b..fbddb46ac 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -40,6 +40,8 @@ import { transferExportQuerySchema, binaryExportResponseSchema, linqWebhookAckResponseSchema, + offrampAvailabilityResponseSchema, + offrampRateQuerySchema, offrampRateResponseSchema, offrampVerifyBankBodySchema, offrampVerifiedBankSchema, @@ -480,12 +482,33 @@ 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", - 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 d3416f5f8..387d9ab91 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -647,10 +647,38 @@ 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({ + /** + * 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({ - rate: z.number(), - currency: z.string(), - coin: z.string(), + 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.", + }), /** Always true: the binding rate is the one locked into an order at creation. */ indicative: z.literal(true), }); 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/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(); 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,