diff --git a/package.json b/package.json index e2527137..c7a6f6f4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", - "build": "tsc", + "build": "prisma generate && tsc", "start": "node dist/index.js", "test": "jest --runInBand", "test:integration": "vitest run --config vitest.integration.config.ts", diff --git a/src/__tests__/amount.test.ts b/src/__tests__/amount.test.ts index 859baeb9..6d7669c5 100644 --- a/src/__tests__/amount.test.ts +++ b/src/__tests__/amount.test.ts @@ -1,3 +1,4 @@ +import { crossCheckWebhookAmount, isNairaAmountPlausible, nairaScale, checkNairaAmount } 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 = checkNairaAmount(20000000000); + expect(result.ok).toBe(false); + expect(result.reason).toBeTruthy(); + }); +}); diff --git a/src/__tests__/routes/idempotency.test.ts b/src/__tests__/routes/idempotency.test.ts index 1135bc92..f16e0c32 100644 --- a/src/__tests__/routes/idempotency.test.ts +++ b/src/__tests__/routes/idempotency.test.ts @@ -157,8 +157,8 @@ beforeEach(() => { process.env.NETWORKS = "testnet,mainnet"; process.env.LINQ_WEBHOOK_SECRET = WEBHOOK_SECRET; linq = installFakeLinq(); - jest.spyOn(console, "warn").mockImplementation(() => {}); - jest.spyOn(console, "error").mockImplementation(() => {}); + jest.spyOn(console, "warn").mockImplementation(() => { }); + jest.spyOn(console, "error").mockImplementation(() => { }); }); afterEach(() => { @@ -307,7 +307,8 @@ describe("recordNgnOrder", () => { walletAddress: WALLET, coin: "usdc", amountStableCoin: 10, - amountNgn: 15000, + amountNGN: 1500000, + amountNgn: 1500000, rate: 1500, status: "pending", }; @@ -375,17 +376,20 @@ describe("POST /webhooks/linq", () => { customerRef: "ref_test_0001", walletAddress: WALLET, coin: "usdc", + amountStableCoin: 10, - amountNgn: 15000, + amountNgn: 1500000, rate: 1500, status: "pending", }); + const app = makeApp(); const event = { event: "order.completed", orderId: "00000000-0000-4000-8000-000000000002", status: "completed", amountStableCoin: 10, + amountNgn: 1500000, }; await deliver(app, event); diff --git a/src/amount.ts b/src/amount.ts index 907c8a78..fe8f4ae8 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, @@ -17,3 +138,9 @@ export function toDisplayAmount( return `${sign}${integer}.${String(remainder).padStart(normalizedDecimals, "0")}`; } +export function nairaScale(){return 100;} +export function isNairaAmountPlausible(value:any){if(typeof value!=='number'||!Number.isFinite(value)||!Number.isInteger(value))return false;if(value<0)return false;if(value<100*100)return false;if(value>1000000000)return false;return true;} +export function checkNairaAmount(value:any){if(!isNairaAmountPlausible(value))return {ok:false,reason:'naira amount is not plausible'};return {ok:true};} +export function crossCheckWebhookAmount(webhookAmountNGN:any,orderAmountNGN:any){const w=Number(webhookAmountNGN);const o=Number(orderAmountNGN);if(!Number.isFinite(w)||!Number.isFinite(o))return {ok:false,reason:'non-finite naira amount'};if(w!==o*100){if(Math.abs(w-o) 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > 2 || ratio < 1 / 2) { + 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: { id: existingOfframp.id }, + data: { + status: + event.status ?? + (event.event === "order.completed" ? "completed" : event.event), + ...(event.amountStableCoin != null + ? { settledStableCoin: String(event.amountStableCoin) } + : {}), + ...(event.amountNGN != null + ? { settledNGN: String(event.amountNGN) } + : {}), + ...(event.txHash ? { depositTxHash: event.txHash } : {}), + }, + }); return; } - await prisma.offrampOrder.update({ - where: { - network_orderId: { network: existing.network ?? network, orderId: event.orderId }, - }, - data: { - status: event.status ?? event.event, - ...(event.amountStableCoin != null - ? { settledStableCoin: String(event.amountStableCoin) } - : {}), - ...(event.amountNGN != null ? { settledNGN: String(event.amountNGN) } : {}), - ...(event.txHash ? { depositTxHash: event.txHash } : {}), - }, + // 2. Fallback to ngnOrder (onramp) table + const existingNgn = await prisma.ngnOrder.findFirst({ + where: { orderId: event.orderId }, }); + + if (existingNgn) { + // Validate NGN bounds if present + if (event.amountNGN != null) { + if (!isNairaAmountPlausible(event.amountNGN)) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_NGN}, ${MAX_NGN}]` + ); + return; + } + const orderNgn = + existingNgn.amountNGN != null + ? Number(existingNgn.amountNGN) + : null; + if (orderNgn != null && orderNgn > 0) { + const ratio = event.amountNGN / orderNgn; + if (ratio > 2 || ratio < 1 / 2) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} disagrees with order amount ${orderNgn} (ratio ${ratio})` + ); + return; + } + } + } + + await prisma.ngnOrder.update({ + where: { id: existingNgn.id }, + data: { + status: + event.status ?? + (event.event === "order.completed" ? "completed" : event.event), + ...(event.amountStableCoin != null + ? { settledStableCoin: String(event.amountStableCoin) } + : {}), + ...(event.amountNGN != null + ? { settledNgn: Number(event.amountNGN) } + : {}), + ...(event.txHash ? { depositTxHash: event.txHash } : {}), + }, + }); + return; + } + + // 3. Not found in either table + console.warn(`[linq] webhook for unknown order ${event.orderId}`); } catch (err) { - // The response has already gone, so this cannot fail the delivery. - // Logged loudly because the reconciliation is now missing and only the - // status poll will catch it up. console.error("[linq] failed to reconcile webhook:", err); } - }, + } ); return router; -} +} \ No newline at end of file diff --git a/src/linq/webhook.ts b/src/linq/webhook.ts index 77085abc..d5b4f503 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: any, orderAmountNGN: any): 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. * @@ -83,3 +164,4 @@ export function parseEvent(rawBody: Buffer | string): LinqWebhookEvent | null { return null; } } +