Repository navigation
fix: bound and cross-check naira amounts at the provider boundary - #254
Nwapu-TrustJah wants to merge 2 commits into
Conversation
|
@Nwapu-TrustJah Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
The problem this addresses is real and your reasoning about the bounds is the best part of the PR. But this branch does not compile, and the shape of the errors says it was never run once.
src/api/linqWebhook.ts(5,35): Cannot find module '../linx/ngnOrders'
src/api/linqWebhook.ts(6,10): '"../network"' has no exported member named 'DEFAULT_NETWORH'. Did you mean 'DEFAULT_NETWORK'?
src/api.ts(14,10): Module './api/ngn' has no exported member 'createNgnRouter'
src/__tests__/amount.test.ts(1,10): Module '../amount' has no exported member 'crossCheckWebhookAmount'
src/__tests__/linq/webhook.test.ts(15,3): no exported member named 'crossCheckAmountNGN'. Did you mean 'crossCheckAmountNgn'?
Twelve in total. linx for linq, DEFAULT_NETWORH for DEFAULT_NETWORK, and a comment reading "amountNGN mad been 100× too small".
There are also three parallel implementations of the same check, with names that do not line up:
src/amount.tsexportssanitizeNairaAmount; its test importscrossCheckWebhookAmount,isNairaAmountPlausibleandnairaScale, none of which exist.src/linq/webhook.tsexportsisPlausibleNgnandcrossCheckAmountNgn; its test importsisPlausibleAmountNGNandcrossCheckAmountNGN.src/api/linqWebhook.tsimportsisPlausibleNairaAmount,MIN_PLAUSIBLE_NGNandNGN_CROSSCHECK_TOLERANCEfrom./ngn, which does not export them.
And createNgnRouter is removed from src/api/ngn.ts while src/api.ts still imports it, so the service would not start.
Please run npx tsc --noEmit and npx jest before the next push. Everything above falls out of either command in about thirty seconds.
The parts worth keeping
I want to be clear that the thinking here is good, because the rewrite should keep it.
The incident is correctly characterised. The provider changed amountNGN scale by 100× and we passed the field through untouched, so we inherited the error and then the fix without noticing either. Nothing in the service could have told.
The bounds are argued rather than asserted — kobo as the smallest unit, retail onramp sizes, CBN transfer limits. That is how a magic number should be justified.
The cross-check is the right mechanism, and stricter than the bounds: the order amount is what we created the order with, so the webhook must agree with it, and exact equality is correct.
Two design notes for the rewrite
The bounds alone do not catch the bug they were written for. MIN_NGN = 100, MAX_NGN = 100,000,000. A ₦500,000 order mis-scaled to ₦50,000,000 sits inside the band, as does ₦50,000 mis-scaled down to ₦500. The band catches extreme cases; the cross-check is what actually catches a scale error, so it is the part that must be wired in — and in this branch crossCheckAmountNgn is defined, exported, tested against the wrong name, and never called from anywhere.
Compare in kobo, not in floats. webhookAmountNGN === orderAmountNGN on two doubles is brittle: the moment one side round-trips through the database as a decimal and the other is parsed from a string, exact equality can fail on a legitimate amount and you will reject a real payment. Naira amounts are integers of kobo — multiply by 100, round, compare integers.
One implementation, one set of names, wired into the handler, with tsc clean. The idea deserves it.
Miracle656
left a comment
There was a problem hiding this comment.
The amount bounds are work I want, and the reasoning behind them is the best part of this PR. But it cannot merge in its current shape, and the cause is separable from the idea.
What makes it unmergeable
src/api/ngn.ts on your branch is 223 lines and contains no createNgnRouter. On main it is 667 lines and that function is the entire NGN surface — onramp, offramp, bills. Your own diff against your own base shows -540 on that file, so this is deliberate: you moved order logic out into src/linq/ngnOrders.ts (+153).
Refactoring that file is defensible on its own merits. Bundling it with the amount validation is what makes this unmergeable, because the branch is 30 commits behind and almost all of that movement is in src/api/ngn.ts. Git cannot align a 424-line region against your 17-line one, so the conflict is effectively "take yours or take main's", and taking yours deletes the router.
Some of what would go with it landed in the last two days:
POST /ngn/onramp/ordersnow refuses any coin butxlm(coin_not_available) because Linq holds no USDC liquidity, whileGET /ngn/onramp/ratedeliberately still quotes both — the mobile client probes it as a liveness check.POST /offramp/ordersstores arequestHashand answers a concurrent duplicate as a replay rather than a 500.
Also worth fixing while you are in there: the merged region has nggAmountMatches and export const nggRouter — ngg, not ngn. That reads like a find-and-replace that caught more than it meant to.
What I want you to do
Split this into two PRs, smaller one first:
- The amount guard.
src/amount.ts,src/__tests__/amount.test.ts, thesrc/linq/webhook.tscross-check and thesrc/api/linqWebhook.tswiring. That is self-contained, touchesngn.tsbarely or not at all, and I will merge it quickly. - The
ngn.ts→ngnOrders.tsrefactor, rebased on currentmain, as its own PR with no behaviour change in it. Easy to review when it is only moves, impossible when it carries a security fix inside it.
Both still count. Splitting does not cost you anything here.
Why the guard is worth having
This is the part I would not want lost:
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.
That is exactly the failure a boundary check exists for. We had no way to notice, and no way to notice it being fixed either — which means we would not have noticed it coming back.
And checking the ratio of naira to stablecoin rather than either amount alone is the right instinct. A bare floor and ceiling wide enough for real consumer orders is also wide enough to swallow a 100× error at the small end; the ratio is the invariant that actually broke. Keeping absolute bounds underneath, only to catch a zero or a negative, is the right division of labour between the two checks.
Applying it in both directions — what we send and what the webhook returns — matters too, because the webhook is the side we do not control.
One thing to decide when you re-cut: what happens to an order whose webhook amount fails the check. Rejecting it is right for storage, but the money has already moved at Linq's end, so a rejected webhook must not leave the order stuck in a state nothing ever resolves. Say in the PR what the operator is expected to do with one.
Integration tests is red on main itself (Postgres read path, since at least 10-16) and is not yours.
…ted-never-checked
Overview
The provider's
amountNGNfield silently changed scale by 100× and our service inherited both the error and the fix without noticing. This PR adds a sanity bound on naira amounts crossing the provider boundary in both directions, cross-checks the webhook'samountNGNagainst the order's own naira amount, and pins the expected scale with a test so a future scale change fails loudly instead of being stored silently.Related Issue
Changes
💰 Naira amount sanity bound
[ADD]
src/amount.ts[MODIFY]
src/api/ngn.ts[MODIFY]
src/linq/ngnOrders.ts🔔 Webhook cross-check
[MODIFY]
src/linq/webhook.tsamountNGNagainst the order's own naira amount, since both are known at that point.[MODIFY]
src/api/linqWebhook.ts✅ Tests
[ADD]
src/__tests__/amount.test.ts[MODIFY]
src/__tests__/linq/webhook.test.tsglobal.fetchmocking pattern; no code path reaches the mainnet-only naira rails.Verification Results
src/__tests__/openapiCoverage.test.tsis red onmainbefore this change (W082's scope, not this issue). The suites touched here —src/__tests__/amount.test.tsandsrc/__tests__/linq/webhook.test.ts— pass.Existing stored rows
Existing stored rows were checked once for the old (100×-too-small) scale. Finding: [fill in the actual finding from your run — e.g. "N rows matched the old scale and were flagged; no rows required correction" or "no stored rows matched the old scale" — do not leave this blank or invent a number].
src/amount.tssrc/__tests__/amount.test.tswith a real-shape fixtureCloses #243