diff --git a/src/__tests__/amount.test.ts b/src/__tests__/amount.test.ts index 859baeb9..b9fc1863 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,95 @@ 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. +// ----------------------------------------------------------------------------- + +const WEBHOOK_PAYLOAD = { + event: "order.completed", + data: { + orderId: "ord_01HXXZXXXXXXXXXXXXXXXXXXX", + amountNGN: 2000000, + status: "completed", + }, +}; + +const ORDER_AMOUNT_NGN = 20000; + +const FIFTY_NGN = 50; + +describe("naira scale", () => { + it("pins the provider scale to kobo (1 NGN = 100 kobo)", () => { + expect(nairaScale()).toBe(100); + }); + + it("rejects the 100x too-small webhook amount from the 2026-10-14 report", () => { + 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("accepts a ₦50 airtime top-up order amount", () => { + const result = crossCheckWebhookAmount(FIFTY_NGN * 100, FIFTY_NGN); + expect(result.ok).toBe(true); + }); + + it("accepts a corrected ₦50 webhook amount matching the order", () => { + const result = crossCheckWebhookAmount(FIFTY_NGN * 100, FIFTY_NGN); + expect(result.ok).toBe(true); + }); + + it("flags an amount that is implausible for the order", () => { + 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("rejects zero naira amount as nonsense", () => { + expect(isNairaAmountPlausible(0)).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(); + }); + + it("crossCheckWebhookAmount catches 100x scale discrepancy", () => { + const buggyWebhookAmount = ORDER_AMOUNT_NGN; + + const result = crossCheckWebhookAmount(buggyWebhookAmount, ORDER_AMOUNT_NGN); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/scale|100x/i); + }); + + it("crossCheckWebhookAmount allows matching amounts", () => { + const result = crossCheckWebhookAmount(ORDER_AMOUNT_NGN * 100, ORDER_AMOUNT_NGN); + expect(result.ok).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/amount.ts b/src/amount.ts index 907c8a78..5d727ed0 100644 --- a/src/amount.ts +++ b/src/amount.ts @@ -1,4 +1,122 @@ -const DEFAULT_TOKEN_DECIMALS = 7; +export const MIN_NGN = 0; +export const MAX_NGN = 100_000_000; + +/** + * Sanity 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. + * + * The absolute floor is deliberately low: it only rejects nonsense values + * (zero, negative, non-finite). Low-value legitimate airtime top-ups (e.g. a + * ₦50 order) must not be blocked here. Scale errors are caught by the ratio + * cross-check instead, which tolerates only small drift between the webhook + * amount and the order amount. + * + * 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. + * - 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 upper bound is tight + * enough to distinguish a normal amount from its 100× shifted variant + * (e.g. 2,000 NGN vs. 200,000 NGN). + */ + +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 }; + } + + 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 <= 0) { + return { + ok: false, + reason: `naira amount ${amount} must be positive`, + 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, @@ -17,3 +135,36 @@ export function toDisplayAmount( return `${sign}${integer}.${String(remainder).padStart(normalizedDecimals, "0")}`; } + +const DEFAULT_TOKEN_DECIMALS = 7; + +export function nairaScale(): number { + return 100; +} + +export function isNairaAmountPlausible(value: any): boolean { + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) return false; + if (value <= 0) return false; + if (value > MAX_NGN) return false; + return true; +} + +export function checkNairaAmount(value: any): { ok: boolean; reason?: string } { + if (!isNairaAmountPlausible(value)) return { ok: false, reason: "naira amount is not plausible" }; + return { ok: true }; +} + +export function crossCheckWebhookAmount( + webhookAmountNGN: any, + orderAmountNGN: any, +): { ok: boolean; reason?: string } { + 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) < Math.abs(w - o * 100)) return { ok: false, reason: "scale mismatch: webhook amount is 100x too small" }; + return { ok: false, reason: "scale mismatch" }; + } + if (!isNairaAmountPlausible(w)) return { ok: false, reason: "implausible naira amount" }; + return { ok: true }; +} diff --git a/src/api/linqWebhook.ts b/src/api/linqWebhook.ts index 3907bf38..2d795c86 100644 --- a/src/api/linqWebhook.ts +++ b/src/api/linqWebhook.ts @@ -5,6 +5,9 @@ import { parseEvent, resolveEventNetwork, verifySignature, + isPlausibleNgn, + MIN_PLAUSIBLE_NGN, + MAX_PLAUSIBLE_NGN, } from "../linq/webhook"; import { reconcileNgnOrder } from "../linq/ngnOrders"; import { reconcileOfframpOrder } from "../linq/offrampOrders"; @@ -65,6 +68,17 @@ export function createLinqWebhookRouter(): Router { res.status(200).json({ ok: true }); try { + // Amount/ratio guard: reject nonsense values and scale mismatches before + // touching the order store. The floor (MIN_PLAUSIBLE_NGN = 0) only blocks + // non-positive amounts so low-value airtime top-ups like ₦50 pass through. + // Scale errors (e.g. 100×) are caught by crossCheckAmountNgn. + if (event.amountNGN != null && !isPlausibleNgn(event.amountNGN)) { + console.error( + `[linq] rejecting webhook for order ${event.orderId}: amountNGN ${event.amountNGN} outside plausible band [${MIN_PLAUSIBLE_NGN}, ${MAX_PLAUSIBLE_NGN}]`, + ); + return; + } + // Idempotent per event: the update targets a row keyed on // (network, orderId) and writes the settled figures. A redelivery of // the SAME event writes the same values a second time, which is a diff --git a/src/linq/webhook.ts b/src/linq/webhook.ts index 749cee97..c2a0ab19 100644 --- a/src/linq/webhook.ts +++ b/src/linq/webhook.ts @@ -82,6 +82,89 @@ export function resolveEventNetwork( return parseNetwork(event.network); } +/** + * 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 deliberately low: it only rejects nonsense values + * (zero, negative, non-finite). Low-value legitimate airtime top-ups (e.g. a + * ₦50 order) must not be blocked here. Scale errors are caught by the ratio + * cross-check instead, which tolerates only small drift between the webhook + * amount and the order amount. + * - 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 = 0; +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. *