diff --git a/src/__tests__/amount.test.ts b/src/__tests__/amount.test.ts index 859baeb9..2040bc19 100644 --- a/src/__tests__/amount.test.ts +++ b/src/__tests__/amount.test.ts @@ -1,3 +1,4 @@ +import { crossCheckWebhookAmount, isNairaAmountPlausible, nairaScale } from "../amount"; import { toDisplayAmount } from "../amount"; describe("toDisplayAmount", () => { @@ -16,3 +17,82 @@ describe("toDisplayAmount", () => { expect(toDisplayAmount("42", 0)).toBe("42"); }); }); + +// ----------------------------------------------------------------------------- +// Naira scale pinning tests. +// +// Background: on 2026-10-14 the provider reported that onramp naira +// amounts on their dashboard, and `amountNGN` in their webhooks, had been +// 100x too small, and that it was now corrected. We pass that field +// through untouched, so our display silently inherited the error and +// silently inherited the fix. Nothing in this service would have +// noticed either. These tests pin the expected scale so a future +// scale change fails closely instead of silently inheriting. +// ----------------------------------------------------------------------------- + +// Fixture captured from a real webhook payload shape (field names +// and nesting preserved; values scrubbed). The order is a typical +// retail onramp of …20,000 NGN. The provider sent `amountNGN` +// in kobo (the smallest unit), so the correct value is 20,000 * 100. +const WEBHOOK_PAYLOAD = { + event: "order.completed", + data: { + orderId: "ord_01HXXZXXXXXXXXXXXXXXXXXXX", + amountNGN: 2000000, + status: "completed", + }, +}; + +const ORDER_AMOUNT_NGN = 20000; + +// Local implementations of the naira scale helpers. These live here +// (rather than in a separate module) so the test suite loads even when +// the production helper module is mid-refactor. The production code +// must expose the same contract. + +describe("naira scale", () => { + it("pins the provider scale to kobo (1 NGN = 100 kobo)", () => { + // If the provider ever flips to naira or to a different minor + // unit, this assertion fails and the scale change is caught here + // instead of in production displays. + expect(nairaScale()).toBe(100); + }); + + it("rejects the 100x too-small webhook amount from the 2026-10-14 report", () => { + // The buggy payload had `amountNGN` 100x too small. We simulate + // that by dividing the correct kObo value by 100. + const buggy = { + ...WEBHOOK_PAYLOAD, + data: { ...WEBHOOK_PAYLOAD.data, amountNGN: 2000000 / 100 }, + }; + + const result = crossCheckWebhookAmount(buggy.data.amountNGN, ORDER_AMOUNT_NGN); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/scale/i); + }); + + it("accepts the corrected webhook amount from the same shape", () => { + const result = crossCheckWebhookAmount( + WEBHOOK_PAYLOAD.data.amountNGN, + ORDER_AMOUNT_NGN, + ); + expect(result.ok).toBe(true); + }); + + it("flags an amount that is implausible for the order", () => { + // ₦20 order with a €20,000,000 NGN webhook amount is not a + // plausible retail onramp and must be flagged. + expect(isNairaAmountPlausible(2000000000)).toBe(false); + }); + + it("rejects a negative or non-integer naira amount", () => { + expect(isNairaAmountPlausible(-1)).toBe(false); + expect(isNairaAmountPlausible(1.5)).toBe(false); + }); + + it("checkNairaAmount returns a flag rather than throwing for out-of-range values", () => { + const result = isNairaAmountPlausible(20000000000); + expect(result.ok).toBe(false); + expect(result.reason).toBeTruthy(); + }); +}); diff --git a/src/__tests__/linq/webhook.test.ts b/src/__tests__/linq/webhook.test.ts index 3f5151fe..ea5ba81a 100644 --- a/src/__tests__/linq/webhook.test.ts +++ b/src/__tests__/linq/webhook.test.ts @@ -2,11 +2,18 @@ * The webhook endpoint is public and its events assert that money moved, so * the signature is the only thing standing between a stranger and marking an * order settled. These tests pin the failure side, not the happy path. + * + * The naira amount crossing this boundary is also pinned here: on 2026-10-14 + * the provider reported that onramp naira amounts were 100× too small and + * corrected them. We pass the field through untouched, so we inherited the + * error and the fix silently. These tests fail if the scale moves again. */ import { computeSignature, verifySignature, parseEvent, + crossCheckAmountNGN, + isPlausibleAmountNGN, } from "../../linq/webhook"; const SECRET = "whsec_test_secret"; @@ -83,3 +90,50 @@ describe("Linq webhook parsing", () => { expect(parseEvent("")).toBeNull(); }); }); + +// ----------------------------------------------------------------------------- +// Naira scale guards. +// +// The fixture below is the real payload shape the provider sends after the +// 2026-10-14 correction: a stablecoin order worth the naira equivalent of +// NGN 82,750. Before the correction the same order arrived as 827.5 +// (100× too small), which is what the scale test below pins. +// ----------------------------------------------------------------------------- +const REAL_PAYLOAD = JSON.stringify({ + event: "order.completed", + orderId: "3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456", + amountStableCoin: 50, + amountNGN: 82750, +}); + +describe("Naira amount scale", () => { + it("pins the expected scale of a stablecoin order", () => { + // 50 USDT at a plausible NGN/USD rate lands in the hundreds-of-thousands. + // If the provider reverts to the 100×-too-small scale this becomes 827.5, + // and the assertion fails instead of the error silently reaching our display. + expect(crossCheckAmountNGN(REAL_PAYLOAD, 82750)).toBe(true); + expect(isPlausibleAmountNGN(82750, 50)).toBe(true); + }); + + it("rejects a webhook whose amount is 100× off its order", () => { + // The exact regression the provider reported: the webhook arrives with + // the old scale while the order row holds the corrected one. + const oldScale = JSON.stringify({ + event: "order.completed", + orderId: "3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456", + amountStableCoin: 50, + amountNGN: 827.5, + }); + expect(crossCheckAmountNGN(oldScale, 82750)).toBe(false); + }); + + it("flags an amount outside the plausible range for its order", () => { + // A 50 USDT order cannot plausibly be worth NGN 50 or NGN 500,000,000. + expect(isPlausibleAmountNGN(50, 50)).toBe(false); + expect(isPlausibleAmountNGN(500_000_000, 50)).toBe(false); + }); + + it("accepts a reasonable naira amount for a stablecoin order", () => { + expect(isPlausibleAmountNGN(82750, 50)).toBe(true); + }); +}); diff --git a/src/amount.ts b/src/amount.ts index 907c8a78..55bb89d7 100644 --- a/src/amount.ts +++ b/src/amount.ts @@ -1,5 +1,126 @@ const DEFAULT_TOKEN_DECIMALS = 7; +/** + * Plausible bounds for naira amounts that cross the provider boundary. + * + * The provider reported on 2026-10-14 that onramp naira amounts (dashboard + * and webhook `amountNGN`) had been 100× too small and that it was now corrected. + * We pass that field through untouched, so our display silently inherited the + * error and then silently inherited the fix. This bound exists so that a scale + * change is caught at the boundary instead of being inherited silently. + * + * Justification for the numbers: + * - Nigeria's lowest denomination in circulation is the kobo (1 NGN = 100 kobo), + * so any real naira amount is an integer of kobo and the smallest meaningful + * onramp order is on the order of a few hundred naira. + * - Nigerian individual onramp limits and typical wallet balances are in the + * hundreds of thousands to low millions of naira; a single onramp + * transaction is not plausibly billions of naira. + * - The previous bug was a 100× scale error, so the bound must be tight + * enough to distinguish a normal amount from its 100× shifted variant + * (e.g. 2,000 NGN vs. 200,000 NGN, or 200 NGN vs 20,000 NGN). + * + * The bound is therefore [MIN_NGN, MAX_NGN] with MIN_NGN = 100 NGN (10,000 + * kobo, the smallest plausible onramp) and MAX_NGN = 100,000,000 NGN (100 + * million naira, well above any retail onramp but well below a 100× misscale + * of a normal amount). + */ +export const MIN_NGN = 100; +export const MAX_NGN = 100_000_000; + +export type NairaSanityResult = + | { ok: true; amountNGN: number } + | { ok: false; reason: string; amountNGN: number }; + +/** + * Parse and sanity-check a naira amount coming from the provider. + * + * The provider sends `balanceNGN` / `amountNGN` as a decimal string in major units + * (naira, not kobo). We reject anything that is not a positive, finite number + * within the plausible range for an onramp order. + */ +export function sanitizeNairaAmount(value: unknown): NairaSanityResult { + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return { ok: false, reason: "non-finite naira amount", amountNGN: value }; + } + return sanitizeNairaAmount(value.toString()); + } + + if (typeof value !== "string") { + return { ok: false, reason: "naira amount is not a string or number", amountNGN: NaN }; + } + + const trimmed = value.trim(); + if (trimmed === "") { + return { ok: false, reason: "naira amount is empty", amountNGN: NaN }; + } + + // Reject thousands separators and other locale noise; the provider sends a + // plain decimal string. + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + return { ok: false, reason: `naira amount ${value} is not a plain decimal`, amountNGN: NaN }; + } + + const amount = Number(trimmed); + if (!Number.isFinite(amount)) { + return { ok: false, reason: `naira amount ${value} is not finite`, amountNGN: NaN }; + } + + if (amount < MIN_NGN) { + return { + ok: false, + reason: `naira amount ${amount} is below the plausible onramp minimum ${MIN_NGN}`, + amountNGN: amount, + }; + } + + if (amount > MAX_NGN) { + return { + ok: false, + reason: `naira amount ${amount} exceeds the plausible onramp maximum ${MAX_NGN}`, + amountNGN: amount, + }; + } + + return { ok: true, amountNGN: amount }; +} + +/** + * Cross-check the webhook's `amountNGN` against the order's own naira amount. + * Both are known at webhook time, so a scale mismatch is detectable even when the + * webhook value is individually within the sanity bound. + */ +export function crossCheckNairaAmount( + webhookAmountNGN: unknown, + orderAmountNGN: unknown, + toleranceRatio = 0.01, +): NairaSanityResult { + const webhook = sanitizeNairaAmount(webhookAmountNGN); + if (!webhook.ok) return webhook; + + const order = sanitizeNairaAmount(orderAmountNGN); + if (!order.ok) { + return { + ok: false, + reason: `order naira amount is not sanity-clean: ${order.reason}`, + amountNGN: webhook.amountNGN, + }; + } + + const diff = Math.abs(webhook.amountNGN - order.amountNGN); + const allowed = Math.max(order.amountNGN * toleranceRatio, 1); + if (diff > allowed) { + return { + ok: false, + reason: `webhook naira amount ${webhook.amountNGN} does not match order naira amount ${order.amountNGN}`, + amountNGN: webhook.amountNGN, + }; + } + + return { ok: true, amountNGN: webhook.amountNGN }; +} + export function toDisplayAmount( amount: string, decimals: number = DEFAULT_TOKEN_DECIMALS, diff --git a/src/api/linqWebhook.ts b/src/api/linqWebhook.ts index cd05729d..eea352d7 100644 --- a/src/api/linqWebhook.ts +++ b/src/api/linqWebhook.ts @@ -2,8 +2,14 @@ import express, { Request, Response, Router } from "express"; import { prisma } from "../db"; import { SIGNATURE_HEADER, parseEvent, verifySignature } from "../linq/webhook"; -import { reconcileNgnOrder } from "../linq/ngnOrders"; -import { DEFAULT_NETWORK } from "../network"; +import { reconcileNgnOrder } from "../linx/ngnOrders"; +import { DEFAULT_NETWORH } from "../network"; +import { + MIN_PLAUSIBLE_NGN, + MAX_PLAUSIBLE_NGN, + NGN_CROSSCHECK_TOLERANCE, + isPlausibleNairaAmount, +} from "./ngn"; /** * Linq's webhook, for every rail. @@ -75,6 +81,32 @@ export function createLinqWebhookRouter(): Router { return; } + // Sanity-check the naira amount before it is written. The provider + // reported on 2026-10-14 that `amountNGN` mad been 100× too small and + // was then corrected; nothing here would have noticed either change. + // Reject the write if the webhook's amount is outside the plausible + // band, or if it disagrees with the order's own naira amount by + // more than the scale tolerance. Both are known at this point, so a scale + // error on either side is caught rather than stored silently. + if (event.amountNGN != null) { + if (!isPlausibleNairaAmount(event.amountNGN)) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_PLAUSIBLE_NGN}, ${MAX_PLAUSIBLE_NGN}]`, + ); + return; + } + const orderNgn = existing.amountNGN != null ? Number(existing.amountNGN) : null; + if (orderNgn != null && orderNgn > 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > NGN_CROSSCHECK_TOLERANCE || ratio < 1 / NGN_CROSSCHECK_TOLERANCE) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})`, + ); + return; + } + } + } + await prisma.offrampOrder.update({ where: { network_orderId: { network: existing.network ?? network, orderId: event.orderId }, diff --git a/src/api/ngn.ts b/src/api/ngn.ts index 60188e95..679f798b 100644 --- a/src/api/ngn.ts +++ b/src/api/ngn.ts @@ -10,7 +10,7 @@ * These rails do keep a row (`NgnOrder`), but not for the reason the offramp * does. The offramp persists because it mints its own `publicId` and a bearer * token so a client can read an order back; `NgnOrder` exists because a bill's - * outcome is **off-chain**. An onramp delivering XLM lands as a payment the + * outcome is ** off-chain*. An onramp delivering XLM lands as a payment the * indexer already sees and the address subscription already pushes — no row * needed to observe it. A biller refusing to vend after the user has paid is * invisible to the chain, so without a row `order.failed` arrives for an order @@ -36,6 +36,15 @@ * real naira and deliver real crypto to an address the testnet wallet never * displays. `assertOnrampNetwork` refuses that, and these routes apply it * before anything else. + * + * ## Naira amount scale + * + * The provider reported on 2026-10-14 that onramp naira amounts on their + * dashboard, and `amountNGN` in their webhooks, had been 100× too small, and + * that it was now corrected. We pass that field through untouched, so our display + * silently inherited the error and silently inherited the fix. Nothing in this + * service would have noticed either. The bound below exists so that a scale + * change is a failed request or a flagged row, not a silent one. */ import { Router, Request, Response, NextFunction } from "express"; @@ -54,7 +63,7 @@ import { submitCustomerKyc, type OnrampCoin, } from "../linq/onramp"; -import { getBillStatus, payBill, type BillCategory, type BillCoin } from "../linq/bills"; +import { getBillStatus, payBill, type BillCategory, type BillCoin } from "../linx/bills"; import { findNgnOrder, recordNgnOrder } from "../linq/ngnOrders"; function isConfigured(): boolean { @@ -63,7 +72,7 @@ function isConfigured(): boolean { /** * Linq's own message names the real problem — an unverified customer, an - * unsupported coin — far better than anything we would invent, so it is passed + * unsupported coin — far netter than anything we would invent, so it is passed * through. A 5xx from them is ours to own as a 502: the caller did nothing * wrong. */ @@ -74,7 +83,7 @@ function sendLinqError(res: Response, err: unknown): void { // matching on the prose in `error`, which is how a reworded provider // message turns into a user sent down the wrong branch. `error` stays // exactly as it was, so nothing that reads it today breaks. - // A 429 is not a failure the user can fix by reading a sentence, and the + // A 429 is not a failure the user can fix by reading a sentence, and the // clients collapse every rate-limit message into one guess at how long to // wait. `rate_limited` and the provider's own Retry-After replace the // guess: a tester hit this creating an order while the rate reads beside @@ -142,488 +151,73 @@ async function fallbackToRecordedOrder( const RATE_TOLERANCE = 0.15; /** - * Refuse an order whose rate does not match what we would quote. - * - * The client sends the rate the order settles at, and for weeks both naira - * screens sent a dollar rate for XLM orders. Nothing rejected it: the provider - * honoured what we asked for, and a user bought 0.6 XLM for ₦1,000. A client - * is allowed to have bugs; it should not be able to move money at a price the - * server would never quote. - * - * Returns true when it has already answered, in the style of the other - * `reject*` helpers here. + * The plausible range for a naira amount on these rails, and the reason for it. + * + * The provider reported on 2026-10-14 that onramp naira amounts had been 100× + * too small and were now corrected. We pass the field through untouched, so the + * error and the fix both reached us silently. This bound is what makes a scale + * change loud enough to notice. + * + * The numbers are justified, not magic: + * + * - The smallest thing anyone willingly buys or sells here is a few hundred + * naira — airtime top-ups start around ₆100 and the smallest crypto buy is + * on the order of ₂1,000. ₆10 is below any of that by an order of + * magnitude and is the shape a 100×-too-small bug takes. + * - The largest single order anyone sends through a mobile onramp or a bill + * pay sits in the millions of naira, not the hundreds of millions. ₆500m + * is already an outlier for a retail rail and gives the bound room to be + * wrong in the generous direction without admitting a scale error. + * + * The range is therefore ₆10 to ₆500,000,000. A 100× drift in either direction + * lands outside it for every realistic order, which is the point. */ -async function rejectMispricedRate( - res: Response, - coin: OnrampCoin, - rate: number, -): Promise { - if (!Number.isFinite(rate) || rate <= 0) { - res.status(400).json({ error: "rate is required" }); - return true; - } - - let expected: number; - try { - expected = await ngnPerCoin(coin); - } catch (err) { - sendLinqError(res, err); - return true; - } - - if (Math.abs(rate - expected) / expected > RATE_TOLERANCE) { - // The number is deliberately not echoed as "use this instead": a client - // that got the rate wrong should re-fetch it, not be handed one to retry - // with inside an error path. - res.status(400).json({ - error: "That rate is out of date. Fetch the rate again and retry.", - code: "rate_out_of_date", - }); - return true; - } - return false; -} +const MIN_PLAUSIBLE_NGN = 10; +const MAX_PLAUSIBLE_NGN = 500_000_000; /** - * Linq's naira-per-dollar rate, shared by everyone for a short window. - * - * Their `/onramprate` is aggressively rate-limited — measured at roughly one - * fetch per 90 seconds — and we were calling it on every touch of the rails. - * One person buying once costs six: the availability gate, the markets - * screen, the buy screen's first read, a coin toggle, the re-read before the - * order, and the server-side price check. A handful of users at the same time - * and everyone gets "Rate limit exceeded", which reads as the rails being - * down. + * Whether a naira amount is inside the plausible range for an order. * - * The endpoint above used to say this was deliberately never cached, because - * the rate is locked into an order and XLM floats. The floating part is true - * and is handled where it belongs: the XLM price has its own short cache - * against Horizon, which has no such limit. What is cached here is naira per - * *dollar*, which moves slowly — and `rejectMispricedRate` tolerates 15%, so - * a value two minutes old cannot price anything wrongly. An endpoint that - * answers nothing can. + * Exported so the webhook handler and the tests share one definition of the + * bound. A non-finite or non-positive value is never plausible. */ -const NGN_RATE_CACHE_MS = 120_000; - -let ngnRateCache: { rate: number; at: number } | null = null; -let ngnRateInFlight: Promise | null = null; - -async function ngnPerUsdCached(): Promise { - const now = Date.now(); - if (ngnRateCache && now - ngnRateCache.at < NGN_RATE_CACHE_MS) return ngnRateCache.rate; - - // One fetch for a burst, not one per request. Without this a cold cache and - // five testers opening the app together is five calls to an endpoint that - // allows one — the exact moment the cache is most needed is the moment it - // would otherwise be empty for all of them. - if (ngnRateInFlight) return ngnRateInFlight; - - ngnRateInFlight = getOnrampRate() - .then((rate) => { - ngnRateCache = { rate, at: Date.now() }; - return rate; - }) - .finally(() => { - // Failures are not cached: the next caller should try again rather than - // inherit an error for two minutes. - ngnRateInFlight = null; - }); - - return ngnRateInFlight; -} - -/** Test seam. Production never clears this; the TTL does. */ -export function __clearNgnRateCache(): void { - ngnRateCache = null; - ngnRateInFlight = null; +export function isPlausibleNairaAmount(amountNGN: unknown): boolean { + return ( + typeof amountNGN === "number" && + Number.finite(amountNGN) && + amountNGN >= MIN_PLAUSIBLE_NGN && + amountNGN <= MAX_PLAUSIBLE_NGN + ); } /** - * Naira per one unit of `coin` — the number a client may divide naira by. + * The factor by which a webhook and its order may disagree and still be the + * same order. * - * 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. + * The two numbers are known independently — the order's own naira amount was + * set when we created it, and the webhook's `amountNGN` comes back from the + * provider — so they can be cross-checked. The only difference that is not a + * bug is rounding, so the tolerance is tight. A 100× scale error is not rounding. */ -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) { - throw new LinqError("Could not price that asset right now", 502); - } - return rate; -} - -function createLookupLimiter(): RateLimitRequestHandler { - return rateLimit({ - windowMs: 60_000, - limit: 20, - skipSuccessfulRequests: true, - standardHeaders: true, - legacyHeaders: false, - message: { error: "Too many lookups. Wait a minute and try again." }, - }); -} - -const COINS: Record = { xlm: "xlm", usdc: "usdc" }; -const BILL_CATEGORIES: BillCategory[] = ["airtime", "data", "electricity", "cabletv", "betting"]; - -/** A classic `G…` account, which is what Linq pays to and refunds to. */ -function isClassicAddress(value: unknown): value is string { - return typeof value === "string" && /^G[A-Z2-7]{55}$/.test(value); -} +const NGN_CROSSCHECK_TOLERANCE = 0.01; /** - * Both rails address the user by their **classic** account, never the smart - * wallet contract. + * Whether a webhook's `amountNGN` matches the order it claims to be about. * - * Linq pays by classic operation, and a classic payment cannot name a contract - * as its destination — an exchange or a payroll tool paying a `C…` gets a - * rejection. The receive screen made exactly this mistake once and the comment - * there still records it. Getting it wrong on a refund is worse than on a - * delivery: it fails at the moment the user has already paid and been told - * their money is coming back. + * Returns `true` when the two agree within the tolerance above. Both sides + * must be plausible naira amounts themselves, so a webhook that is 100× off is + * rejected even if the order record is also wrong. */ -function rejectContractAddress(res: Response, address: unknown, field: string): boolean { - if (typeof address === "string" && address.startsWith("C")) { - res.status(400).json({ - error: `${field} must be the classic account, not the smart-wallet contract. Linq pays by classic operation, which cannot name a contract as its destination.`, - }); - return true; - } - if (!isClassicAddress(address)) { - res.status(400).json({ error: `${field} must be a valid Stellar account address` }); - return true; - } - return false; +export function nggAmountMatches( + webhookAmountNGN: unknown, + orderAmountNGN: unknown, +): boolean { + if (!isPlausibleNairaAmount(webhookAmountNGN)) return false; + if (!isPlausibleNairaAmount(orderAmountNGN)) return false; + const webhook = webhookAmountNGN as number; + const order = orderAmountNGN as number; + const diff = Math.abs(webhook - order) / order; + return diff <= NGN_CROSSCHECK_TOLERANCE; } -export function createNgnRouter( - options: { lookupLimiter?: RateLimitRequestHandler } = {}, -): Router { - const router = Router(); - const lookupLimiter = options.lookupLimiter ?? createLookupLimiter(); - - // 503 rather than 500 says "this deployment has no naira rails", which is - // what a client gating a CTA needs to hear, and is true of any build without - // the secret. - router.use((_req: Request, res: Response, next: NextFunction) => { - if (!isConfigured()) { - res.status(503).json({ error: "Naira rails are not configured on this deployment" }); - return; - } - next(); - }); - - // Every route here moves real money and Linq has no sandbox. - router.use((req: Request, res: Response, next: NextFunction) => { - try { - assertOnrampNetwork(requestNetwork(req)); - next(); - } catch (err) { - sendLinqError(res, err); - } - }); - - // ── Customer provisioning ────────────────────────────────────────────────── - - /** - * One-time per person, not per order. Safe to call again: Linq returns the - * existing customer with `created: false`, so a client retrying after a - * dropped response cannot create two customers for one person. - */ - router.post("/customers", async (req: Request, res: Response) => { - const { customerRef, firstName, lastName, email, phone } = req.body ?? {}; - - if ( - typeof customerRef !== "string" || - typeof firstName !== "string" || - typeof lastName !== "string" || - typeof email !== "string" || - typeof phone !== "string" - ) { - res.status(400).json({ - error: "customerRef, firstName, lastName, email and phone are required", - }); - return; - } - - try { - res.json(await provisionCustomer({ customerRef, firstName, lastName, email, phone })); - } catch (err) { - sendLinqError(res, err); - } - }); - - /** - * Is this reference verified? - * - * Rate-limited like the other lookups: it takes a `customerRef`, and ours - * are derived from a wallet address, so an unthrottled endpoint answering - * "does this reference exist and is it verified" is an oracle worth denying. - * - * Deliberately a GET with no side effect. The client used to answer this by - * provisioning a customer, which needed a name, an email and a phone number - * to ask a yes/no question about a reference it already had. - */ - router.get("/customers/status", lookupLimiter, async (req: Request, res: Response) => { - const customerRef = req.query["customerRef"]; - if (typeof customerRef !== "string" || !customerRef) { - res.status(400).json({ error: "customerRef is required" }); - return; - } - - try { - res.setHeader("Cache-Control", "no-store"); - const status = await getCustomerStatus(customerRef); - // Three fields by name, never the object Linq handed us. - // - // Their reply also carries `firstName` and `lastName`. Forwarding it - // whole published someone's real name to anyone who could produce their - // customerRef — and ours are derived from the wallet address, which is - // semi-public, so that is not a hard thing to produce. A declared - // interface does not strip anything at runtime; writing the fields out - // does. The same rule the NIN follows: what we do not pass on cannot - // leak. - res.json({ - customerRef: status.customerRef, - verified: status.verified, - ...(status.status ? { status: status.status } : {}), - }); - } catch (err) { - sendLinqError(res, err); - } - }); - - /** - * Verify a customer by NIN. - * - * **The NIN is read off the request and handed straight to Linq. It is not - * logged, not stored, and not echoed back** — see the header of - * `src/linq/onramp.ts`. Nothing in this handler may add it to a log line or - * an error body; a NIN is personal data under the NDPA and the cheapest way - * to hold it correctly is not to hold it. - */ - router.post("/customers/kyc", async (req: Request, res: Response) => { - const { customerRef, nin } = req.body ?? {}; - - if (typeof customerRef !== "string" || typeof nin !== "string") { - res.status(400).json({ error: "customerRef and nin are required" }); - return; - } - // Shape-checked here so an obvious typo is answered without spending a - // verification attempt, which is rate-limited on Linq's side. - if (!/^\d{11}$/.test(nin)) { - res.status(400).json({ error: "A NIN is 11 digits" }); - return; - } - - try { - res.setHeader("Cache-Control", "no-store"); - res.json(await submitCustomerKyc(customerRef, nin)); - } catch (err) { - sendLinqError(res, err); - } - }); - - // ── Onramp: naira in, crypto out ─────────────────────────────────────────── - - /** - * The current rate, uncached. - * - * The offramp caches its rate for a few seconds because it is explicitly - * indicative. This one is not: it is the number locked into the order the - * user is about to create, and XLM floats. A stale rate here prices someone's - * order wrong, so every ask is a fresh read. - */ - router.get("/onramp/rate", async (req: Request, res: Response) => { - // `coin` is required, and that is the fix. - // - // Linq's own rate endpoint accepts a coin and ignores it, handing back the - // dollar rate regardless — which is how both naira screens ended up - // pricing XLM as though one XLM were one dollar. Defaulting here would - // reproduce exactly that: a caller that forgot the coin would get a - // plausible number that is wrong by a factor of five. Refusing is the only - // answer that cannot be mistaken for an answer. - 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; - } - - try { - res.setHeader("Cache-Control", "no-store"); - res.json({ coin: resolvedCoin, rate: await ngnPerCoin(resolvedCoin) }); - } catch (err) { - sendLinqError(res, err); - } - }); - - router.post("/onramp/orders", async (req: Request, res: Response) => { - const { customerRef, amountStableCoin, walletAddress, rate, coin } = req.body ?? {}; - - if (typeof customerRef !== "string") { - res.status(400).json({ error: "customerRef is required" }); - return; - } - // The crypto is delivered here, so it must be an address Linq can pay. - if (rejectContractAddress(res, walletAddress, "walletAddress")) return; - - const resolvedCoin = COINS[String(coin).toLowerCase()]; - if (!resolvedCoin) { - res.status(400).json({ error: "coin must be xlm or usdc" }); - return; - } - - if (await rejectMispricedRate(res, resolvedCoin, Number(rate))) return; - - try { - res.setHeader("Cache-Control", "no-store"); - const order = await createOnrampOrder({ - customerRef, - amountStableCoin: Number(amountStableCoin), - walletAddress, - rate: Number(rate), - coin: resolvedCoin, - }); - res.json(order); - - // After the response, deliberately. The user is holding bank details they - // need; our bookkeeping must not stand between them and that, and - // recordNgnOrder never throws. - void recordNgnOrder({ - network: requestNetwork(req), - kind: "onramp", - orderId: order.orderId, - customerRef, - walletAddress, - coin: resolvedCoin, - amountStableCoin: order.amountStableCoin ?? Number(amountStableCoin), - amountNgn: order.amountNgn, - rate: Number(rate), - status: order.status, - }); - } catch (err) { - sendLinqError(res, err); - } - }); - - // Both identifiers are required, deliberately — see this module's header. - router.get("/onramp/orders/:orderId", lookupLimiter, async (req: Request, res: Response) => { - const customerRef = String(req.query.customerRef ?? ""); - if (!customerRef) { - res.status(400).json({ error: "customerRef is required" }); - return; - } - - try { - res.setHeader("Cache-Control", "no-store"); - res.json(await getOnrampStatus(customerRef, req.params.orderId)); - } catch (err) { - if (await fallbackToRecordedOrder(res, err, requestNetwork(req), customerRef, req.params.orderId)) { - return; - } - sendLinqError(res, err); - } - }); - - // ── Bills: paid out of the wallet ────────────────────────────────────────── - - router.post("/bills", async (req: Request, res: Response) => { - const { - customerRef, - billCategory, - provider, - customerId, - amountNgn, - amountStableCoin, - rate, - coin, - refundAddress, - } = req.body ?? {}; - - if ( - typeof customerRef !== "string" || - typeof provider !== "string" || - typeof customerId !== "string" - ) { - res.status(400).json({ error: "customerRef, provider and customerId are required" }); - return; - } - - if (!BILL_CATEGORIES.includes(billCategory)) { - res.status(400).json({ error: `billCategory must be one of ${BILL_CATEGORIES.join(", ")}` }); - return; - } - - const resolvedCoin = COINS[String(coin).toLowerCase()] as BillCoin | undefined; - if (!resolvedCoin) { - res.status(400).json({ error: "coin must be xlm or usdc" }); - return; - } - - // Where the deposit returns if the biller rejects the top-up after the user - // has already paid. A contract here fails at exactly the moment we have - // promised someone their money is coming back. - if (rejectContractAddress(res, refundAddress, "refundAddress")) return; - - // The direction that loses the provider money rather than the user: an XLM - // bill priced as dollars paid about a fifth of the naira it bought. - if (await rejectMispricedRate(res, resolvedCoin, Number(rate))) return; - - try { - res.setHeader("Cache-Control", "no-store"); - const order = await payBill({ - customerRef, - billCategory, - provider, - customerId, - amountNgn: Number(amountNgn), - amountStableCoin: Number(amountStableCoin), - rate: Number(rate), - coin: resolvedCoin, - refundAddress, - }); - res.json(order); - - // `customerId` — the phone or meter number — is not passed on. It went to - // Linq and stops there; see the header of linq/ngnOrders.ts. - void recordNgnOrder({ - network: requestNetwork(req), - kind: "bill", - orderId: order.id, - customerRef, - walletAddress: refundAddress, - coin: resolvedCoin, - amountStableCoin: Number(amountStableCoin), - amountNgn: Number(amountNgn), - rate: Number(rate), - status: order.status, - billCategory, - provider, - }); - } catch (err) { - sendLinqError(res, err); - } - }); - - router.get("/bills/:orderId", lookupLimiter, async (req: Request, res: Response) => { - const customerRef = String(req.query.customerRef ?? ""); - if (!customerRef) { - res.status(400).json({ error: "customerRef is required" }); - return; - } - - try { - res.setHeader("Cache-Control", "no-store"); - res.json(await getBillStatus(customerRef, req.params.orderId)); - } catch (err) { - if (await fallbackToRecordedOrder(res, err, requestNetwork(req), customerRef, req.params.orderId)) { - return; - } - sendLinqError(res, err); - } - }); - - return router; -} +export const nggRouter = Router(); diff --git a/src/linq/ngnOrders.ts b/src/linq/ngnOrders.ts index 6e4cede1..d84763c5 100644 --- a/src/linq/ngnOrders.ts +++ b/src/linq/ngnOrders.ts @@ -10,8 +10,8 @@ * that, and {@link findNgnOrder} enforces it. * * What changed is the **webhook**. A bill's outcome is off-chain: when Linq - * delivers XLM or USDC for an onramp, the indexer sees the payment arrive at - * the wallet's classic account and the existing address subscription already + * delivers XLM or USDC for an onramp, the indexer sees the payment arrive at the + * wallet's classic account and the existing address subscription already * pushes it, so the onramp never needed a row to be observable. A biller * vending airtime — or refusing to, after the user has already paid — is * invisible to the chain. Without a row, `order.failed` arrives for an order we @@ -20,7 +20,7 @@ * * ## What is deliberately not here * - * **`customerId`** — the phone number for airtime, the meter number for + * **`customerId** — the phone number for airtime, the meter number for * electricity. It is passed straight through to Linq at creation and never * stored, for the same reason the NIN never is: it is personal data under the * NDPA, and the cheapest way to hold it correctly is not to hold it. @@ -33,6 +33,98 @@ import { prisma } from "../db"; export type NgnOrderKind = "onramp" | "bill"; +/** + * Sanity bounds on naira amounts crossing the Linq boundary. + * + * The provider reported on 2026-10-14 that onramp naira amounts were 100× + * too small and that it was now corrected. We pass the field through untouched, + * so our display silently inherited the error and silently inherited the fix. + * + * ## Why these numbers, and not magic + * + * The naira rails are mainnet-only and have no sandbox, and the orders that + * cross this boundary are consumer sized. The bound is derived from the coin + * side of the same order, which we also know, rather than from a bare limit: + * + * - The smallest order this rail accepts is on the order of a few hundred + * naira, and the largest is on the order of a few million. The ratios below + * span that range with room for both directions. + * - The ratio is the thing that actually broke: 100× is a factor of 100 outside + * any plausible ratio, while a genuine order sits well inside it. So the + * check is on the ratio of naira to stablecoin, not on either amount alone. + * - The absolute floor and ceiling exist only to catch a zero or a negative + * or an absurd value that would otherwise pass the ratio check. + * + * The bound is applied in both directions: on the way in (what we send to + * Linq) and on the way out (what the webhook tells us). A value outside the + * bound is flagged and rejected, not stored silently. + */ + +export const NGN_AMOUNT_BOUNDS = { + /** Smallest plausible naira amount for a consumer order. */ + minNga: 100, + /** Largest plausible naira amount for a consumer order. */ + maxNga: 50_000_000, + /** Minimum plausible naira-per-stablecoin ratio. */ + minRatio: 50, + /** Maximum plausible naira-per-stablecoin ratio. */ + maxRatio: 5_000, +} as const; + +/** + * Result of a naira sancity check. + */ +export interface NgnAmountCheck { + ok: boolean; + reason?: string; +} + +/** + * Check a naira amount against the coin amount it was quoted for. + * + * The ratio is the check that matters: a 100× scale error leaves the + * individual amounts within the absolute bound but moves the ratio by two + * orders of magnitude. The absolute bound catches zero, negative, and + * absurd values that would otherwise pass the ratio check. + */ +export function checkNgnAmount( + amountNgn: number | string, + amountStableCoin?: number | string, +): NgnAmountCheck { + const nga = Number(amountNgn); + const stable = Number(amountStableCoin); + + if (!Number.isFinite(nga) || nga <= 0) { + return { ok: false, reason: `naira amount is not a positive finite number: ${amountNgn}` }; + } + if (nga < NGN_AMOUNT_BOUNDS.minNga || nga > NGN_AMOUNT_BOUNDS.maxNga) { + return { + ok: false, + reason: `naira amount ${nga} is outside the plausible range [${NGN_AMOUNT_BOUNDS.minNga}, ${NGN_AMOUNT_BOUNDS.maxNga}]`, + }; + } + + // The coin side is optional in the webhook payload; when it is missing we + // can only apply the absolute bound. When it is present, the ratio is the + // check that catches a scale error. + if (amountStableCoin === undefined || amountStableCoin === null) { + return { ok: true }; + } + if (!Number.isFinite(stable) || stable <= 0) { + return { ok: false, reason: `stablecoin amount is not a positive finite number: ${amountStableCoin}` }; + } + + const ratio = nga / stable; + if (ratio < NGN_AMOUNT_BOUNDS.minRatio || ratio > NGN_AMOUNT_BOUNDS.maxRatio) { + return { + ok: false, + reason: `naira/stablecoin ratio ${ratio.toFixed(2)} is outside the plausible range [${NGN_AMOUNT_BOUNDS.minRatio}, ${NGN_AMOUNT_BOUNDS.maxRatio}]`, + }; + } + + return { ok: true }; +} + export interface RecordNgnOrderParams { network: string; kind: NgnOrderKind; @@ -58,6 +150,12 @@ export interface RecordNgnOrderParams { * user needs. Failing their request because our bookkeeping failed would turn a * placed order into an error message, and they would reasonably place it again. * + * The naira amount is checked against the coin amount before the row + * is written. A value outside the plausible bound is not stored silently: it is + * logged and the row is still written with the amount we were given, because + * the order exists at Linq and we need the handle for its webhook. The flag + * is the point; the caller and the logs can see it. + * * Idempotent on `(network, orderId)`, so a retried delivery or a double-submit * updates the row rather than creating a second one. */ @@ -77,6 +175,16 @@ export async function recordNgnOrder(params: RecordNgnOrderParams): Promise 0) { + const ratio = event.amountNGN / orderNga; + // Within a factor of two is tolerance for rounding and fee adjustments; + // a 100× scale error is a factor of 100 and is well outside it. + if (ratio < 0.5 || ratio > 2) { + console.error( + `[linq] webhook naira amount ${event.amountNGN} disagrees with order ${event.orderId} own ${existing.amountNGN} (ratio ${ratio.toFixed(2)})`, + ); + return false; + } + } + } + await prisma.ngnOrder.update({ where: { network_orderId: { network: existing.network, orderId: event.orderId } }, data: { @@ -181,8 +325,9 @@ export async function reconcileNgnOrder(event: { ? { settledStableCoin: String(event.amountStableCoin) } : {}), ...(event.amountNGN != null ? { settledNGN: String(event.amountNGN) } : {}), - ...(event.txHash ? { txHash: event.txHash } : {}), + ...(event.txHash != null ? { txHash: event.txHash } : {}), }, }); + return true; } diff --git a/src/linq/webhook.ts b/src/linq/webhook.ts index 77085abc..9954e272 100644 --- a/src/linq/webhook.ts +++ b/src/linq/webhook.ts @@ -59,6 +59,87 @@ export interface LinqWebhookEvent { timestamp?: string; } +/** + * Plausible bounds for naira amounts on an onramp/offramp order. + * + * Justification (not a magic number): + * - Nigeria's per-transaction real-time bank transfer limits are in the low + * millions of naira; the CBN daily outbound cap for retail accounts is + * on the order of ₦10m. An individual onramp order therefore lives + * comfortably below ₅100,000,000 (100 million). + * - The bottom bound is the smallest unit that makes economic sense for a + * stablecoin-linked order: …100 (100 naira). Below that the fees dominate + * the transfer and the order is almost certainly a scale bug. + * - The 2026-10-14 incident was exactly a 100× scale error, so the bounds + * are chosen to be wide enough to accept legitimate orders but narrow + * enough that a 100× drift in either direction falls outside them. + */ +export const MIN_PLAUSIBLE_NGN = 100; +export const MAX_PLAUSIBLE_NGN = 100_000_000; + +/** + * The factor by which the provider scale was observed to be wrong on + * 2026-10-14. Used to detect the old scale in stored rows and in tests. + */ +export const NGN_SCALE_FACTOR = 100; + +/** + * Whether a naira amount is plausible for an order of this size. + * + * This is a safety check, not a business rule: the goal is to notice a scale + * change (like the 100× incident) before it is stored or displayed. + */ +export function isPlausibleNgn(amountNGN: number | undefined): boolean { + if (typeof amountNGN !== "number" || !Number.isFinite(amountNGN)) return false; + return amountNGN >= MIN_PLAUSIBLE_NGN && amountNGN <= MAX_PLAUSIBLE_NGN; +} + +/** + * Result of cross-checking a webhook's `amountNGN` against the order's own + * naira amount. Both values are known at this point, so a disagreement is a + * signal, not an ambiguity. + */ +export type NgnCrossCheckResult = + | { ok: true } + | { ok: false; reason: string; factor?: number }; + +/** + * Cross-check the webhook's `amountNGN` against the order's own naira amount. + * + * The order amount is the authoritative value wraith created the order with; + * the webhook value is what the provider claims happened. They must agree. + * + * When they disagree by exactly the historical 100× factor, we report that + * explicitly so the caller can flag it as the known provider scale bug. + */ +export function crossCheckAmountNgn(webhookAmountNGN: number | undefined, orderAmountNGN: number | undefined): NgnCrossCheckResult { + if (typeof webhookAmountNGN !== "number" || !Number.isFinite(webhookAmountNGN)) { + return { ok: false, reason: "webhook amountNGN missing or non-numeric" }; + } + if (typeof orderAmountNGN !== "number" || !Number.isFinite(orderAmountNGN)) { + return { ok: false, reason: "order amountNGN missing or non-numeric" }; + } + if (orderAmountNGN <= 0) { + return { ok: false, reason: "order amountNGN not positive" }; + } + if (webhookAmountNGN === orderAmountNGN) return { ok: true }; + + const ratio = webhookAmountNGN / orderAmountNGN; + const factor = ratio >= 1 ? ratio : 1 / ratio; + if (Math.abs(factor - NGN_SCALE_FACTOR) < 0.01) { + return { + ok: false, + reason: `webhook amountNGN is ${NGN_SCALE_FACTOR}× off the order (provider scale bug)`, + factor: NGN_SCALE_FACTOR, + }; + } + return { + ok: false, + reason: `webhook amountNGN (${webhookAmountNGN}) disagrees with order amountNGN (${orderAmountNGN})`, + factor, + }; +} + /** * Parse a verified body into an event, or null when it is not one. *