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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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, checkNairaAmount } from "../amount";
import { toDisplayAmount } from "../amount";

describe("toDisplayAmount", () => {
Expand All @@ -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();
});
});
12 changes: 8 additions & 4 deletions src/__tests__/routes/idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -307,7 +307,8 @@ describe("recordNgnOrder", () => {
walletAddress: WALLET,
coin: "usdc",
amountStableCoin: 10,
amountNgn: 15000,
amountNGN: 1500000,
amountNgn: 1500000,
rate: 1500,
status: "pending",
};
Expand Down Expand Up @@ -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);
Expand Down
127 changes: 127 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 All @@ -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)<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};}


Loading
Loading