Skip to content

Latest commit

 

History

History
533 lines (419 loc) · 25.6 KB

ngn-rails.md

File metadata and controls

533 lines (419 loc) · 25.6 KB

Naira rails

Three rails move money between naira and a Veil wallet, all through Linq:

Rail Direction Route prefix Order storage
Offramp USDC/XLM out → NGN to a bank account /offramp Persisted (OfframpOrder)
Onramp NGN in from a bank transfer → XLM/USDC to a wallet /ngn/onramp NgnOrder — for the webhook, not for reads
Bills Wallet crypto → airtime, data, electricity, cable TV, betting /ngn/bills NgnOrder — same table, and the reason it exists

The offramp is documented for clients in offramp-orders.md; this file is the whole surface, including the parts a client never sees.

Why any of this is server-side

The Linq API key is the ability to spend — it creates orders that move real naira — and anything shipped in an APK is extractable. The app never holds it. Every call below is made by wraith with X-API-Key, never by a client, and never with a consumer bearer token.

The rules that apply to all three

Mainnet only, and it is not a configuration preference

Linq has no sandbox. Every call moves real money. A Stellar G… address is valid on both networks, so an order placed from a testnet session would take real naira out of someone's bank and deliver real crypto to an address their testnet wallet will never display. They would have paid and seen nothing.

assertOnrampNetwork refuses that outright. On /ngn it is applied as router-level middleware, so it runs before any handler — a new route added to that router is gated by default rather than by the author remembering.

GET /ngn/onramp/rate          (x-network: testnet)
→ 400  { "error": "…only available on mainnet", "code": "mainnet_only" }

Nothing reaches Linq. networkMiddleware also refuses a network the deployment has not enabled, so NETWORKS must list mainnet for any of this to answer at all.

An unconfigured deployment says so

Without LINQ_API_KEY, /ngn returns 503, not 500:

{ "error": "Naira rails are not configured on this deployment", "code": "not_configured" }

That is what a client gating a CTA needs to hear, and it is true of any build without the secret.

Addresses are classic G…, never the smart-wallet contract

Linq pays by classic operation, and a classic payment cannot name a contract as its destination. rejectContractAddress refuses a C… wherever Linq would be asked to pay to it — the onramp's walletAddress and the bills refundAddress — and also refuses anything that is not a well-formed G….

Getting this wrong on a refund is worse than on a delivery: it fails at the moment the user has already paid and been told their money is coming back. The receive screen made this exact mistake once.

Errors

Every failure on /ngn and /offramp answers one shape (#287):

{ "error": "human prose", "code": "stable_machine_readable_code" }
  • code is the machine-readable half. snake_case, never renamed — installed APKs already branch on coin_not_available and rate_out_of_date, so an existing code keeps its value forever. This is the field to switch on.
  • error is prose for a person. It changes when the provider rewords an error; never match on it. On /offramp the provider's name is scrubbed out of it before it is sent.
  • Nothing else rides along. Bodies are written field by field, so a stack, a connection string or a key cannot reach a response. The one exception is retryAfterSeconds, present only beside a provider 429 — and the offramp's trustline refusal, which also carries valid: false, trustsUSDC: false for the screen that asked.

Linq's own message still reaches the client — their 400s name the real problem (an unverified customer, an unsupported coin) far better than anything we would invent — but always behind a code the client can switch on.

The codes

Wraith-assigned codes (the closed set; see RAIL_ERROR_CODES in src/api/errors.ts):

code Meaning
not_configured This deployment has no LINQ_API_KEY; the rail is off (503).
invalid_request A required field is missing or malformed (400).
invalid_coin coin is present but not one this rail prices (400).
invalid_address Not a classic G… account Linq can pay to (400).
invalid_network ?network= names no known network (400).
network_not_enabled A real network this deployment does not serve (400).
mainnet_only The rails refuse to move money on testnet — Linq has no sandbox (400).
veil_rate_limited Our rate limiter refused the request (429).
rate_limited The provider's shared rate limit, with retryAfterSeconds when sent (429).
rate_out_of_date The submitted rate differs from ours beyond the 15% tolerance (400).
coin_not_available The provider cannot source this coin right now (400).
invalid_nin_format A NIN is not 11 digits — the same code from our check and Linq's (400).
idempotency_conflict The idempotencyKey was used for a different request (409).
unauthorized No order access token on GET /offramp/orders/:orderId (401).
order_not_found Unknown id, malformed id, or a token that is not its — indistinguishable (404).
provider_rejected The provider refused (4xx) with no usable code of its own.
provider_error The provider failed (5xx) with no usable code of its own (answered as 502).
provider_timeout The provider did not answer in time (504).
provider_unreachable No connection to the provider was made (502).
not_found No route matched (404).
origin_not_allowed The browser's origin is not on CORS_ALLOWED_ORIGINS (403). The request never reached the rail.
internal_error An unexpected failure on our side (500).

Provider codes pass through verbatim in the same position — nin_already_used, customer_not_verified, customer_not_found and the rest. They are an open set: a client must default on a code it does not recognise. They are never remapped onto the codes above; a mapped guess sends a person down the wrong branch with confident copy.

A code is never absent. When the provider fails without one, wraith assigns a fallback from the status alone — rate_limited for a 429, provider_rejected for any other 4xx, provider_error for a 5xx — because a client cannot branch on a shape that sometimes lacks the field.

Linq status We return code when Linq sent none
4xx the same status, with Linq's message provider_rejected (429 → rate_limited)
5xx 502 — the caller did nothing wrong provider_error
timeout 504 provider_timeout
unreachable 502 provider_unreachable

Every money-moving response carries Cache-Control: no-store.

Timeouts

15s per request, 35s total budget per logical operation including retries. A server answering a person has to finish before their client gives up and tells them it failed.


Onramp — naira in, crypto out

This closes the cold-start problem no amount of wallet code can: a brand-new Stellar account needs 1 XLM of base reserve plus 0.5 XLM per trustline before it can hold a single USDC, and until now the only ways to get that first XLM were to already own crypto or for us to fund it.

1. Provision the customer — once per person, not per order

POST /ngn/customers
{ customerRef, firstName, lastName, email, phone }
→ 200 { customerRef, verified, created }

Safe to call again: Linq returns the existing customer with created: false, so a client retrying after a dropped response cannot create two customers for one person. It is keyed on customerRef alone, though, so a repeat with different details keeps the first ones. What is and is not safe to repeat across all the rails is in idempotency.md.

2. Verify by NIN — once per person, ever

Confirmed with Linq, 2026-10-03. Their NIN check is NIN only — no liveness and no face-match against the NIMC photo. They do not expose the sender's bank account name on an onramp order. But a NIN that has already verified one customer cannot verify a second: uniqueness is enforced.

Three consequences, and the third is the one that bites:

  1. A stolen NIN still verifies. NIN slips circulate widely in Nigeria, so the check proves possession of a number, not identity. The blast radius is bounded by uniqueness — one stolen NIN buys one account, not a farm — but that one account's KYC record names an innocent person.
  2. The obvious free mitigation is unavailable. Matching the sender's bank account name to the verified name would force an attacker to also hold a bank account in the victim's name, and Nigerian accounts are BVN-bound. Linq does not expose the payer, so this cannot be built on their data. The f3 design screen renders From · GTBank ··4821, which this does not support.
  3. Uniqueness turns a changed customerRef into a lockout. A reference that drifts is not a new name for the same person — it is a stranger whose NIN is already spent. The mobile client therefore persists customerRef (getCustomerRef in lib/onramp.ts) rather than deriving it per read. A user who creates a genuinely new wallet, or reinstalls without a backup, still lands on "NIN already used" with no way out.

The open question that matters most: can an existing customer be resolved or re-bound for a NIN that is already verified? Without it, a reinstall is a permanent loss of the naira rails for that person. Ask before public launch.

Liveness, if wanted, is now a Veil-side build (Smile ID, Dojah, Prembly and QoreID all do NIN-with-selfie). Weigh it carefully: biometric data is sensitive personal data under the NDPA, so it pulls in a DPIA and raises the stakes on NDPC registration — and it would not replace Linq's check, which stays the one of record. If built, the capture must go device → provider directly so no image ever touches wraith, and it must run before Linq's KYC so a failure never spends one of their rate-limited attempts.

POST /ngn/customers/kyc
{ customerRef, nin }
→ 200 { customerRef, verified, status }

The NIN rule. The NIN is read off the request and handed straight to Linq. It is never stored, never logged, never returned, and never placed in any type that is persisted. There is deliberately no nin field on LinqCustomer or CustomerKycResult, so there is nothing to accidentally write to a database or an error report. A test asserts the NIN does not appear in the response body.

This is not tidiness. A NIN is Nigerian personal data under the NDPA, and the cheapest way to hold it correctly is not to hold it. Any future change that adds a NIN to a stored shape changes what this service legally is.

An 11-digit shape check runs here so an obvious typo is answered without spending a verification attempt, which Linq rate-limits.

Retries. A NIN verifies once, ever, so a provider blip must not read as a refusal. submitCustomerKyc retries a provider 5xx or a connection that got no answer, with 1s then 2s backoff, at most three attempts, all inside one 35s budget, which is under the clients' 40s ceiling. It never retries a 4xx (nin_already_used, invalid_nin_format, a 429): those are answers, and each retry spends a rate-limited attempt. It never retries its own timeout either, because the provider may still be working on the NIN it already sent.

Before any resend it asks GET /b2b/customers/status, which takes the reference alone. If the customer is already verified (the attempt landed and only the answer was lost), that is returned and the NIN is not sent again. If the status cannot be read either, the original failure is returned rather than resending blind.

Failures. The provider documented these on 2026-10-14. code is forwarded verbatim; branch on it, never on error.

code Provider status wraith answers
invalid_nin_format 400 400 — also sent by wraith's own 11-digit check
nin_already_used 409 409
nin_verification_failed 422 422
kyc_provider_error 502 502
kyc_internal_error 500 502 (a provider 5xx is ours to own)

An undocumented code is passed through as sent, never mapped onto one of these. A body with no usable code gets wraith's stable fallback rather than an absent key (#287): provider_rejected for a 4xx, provider_error for a 5xx — and error still says only that the request failed and its status, never a cause nobody sent. Every case, with the provider's body and wraith's answer side by side, is in fixtures/linq/kyc-errors.json; the clients test against that same file.

3. Lock a rate

GET /ngn/onramp/rate
→ 200 { rate: 1356.15 }

Never cached. The offramp caches its rate for a few seconds because that one is explicitly indicative. This one is not — it is the number locked into the order the user is about to create, and XLM floats. A stale rate here prices someone's order wrong.

Linq returns a bare number here, not an object like /b2b/rate does, so it is parsed defensively and anything ≤ 0 is refused rather than passed on as a price.

The path says onramp; the side of the book depends on the coin. coin=xlm is quoted from Linq's buy side (/onramprate). coin=usdc is quoted from the sell side (/b2b/rate), because the only thing USDC buys on these routes is a bill — see Which rate a bill is priced at. The rule is one function, ngnRail in src/api/ngn.ts.

4. Create the order

POST /ngn/onramp/orders
{ customerRef, amountStableCoin, walletAddress, rate, coin: "xlm" | "usdc" }
→ 200 { orderId, customerRef,
        accountNumber, bankName, accountName,
        amountNgn, amountStableCoin, fee,
        expiresAt, status }

The response carries the Nigerian bank account to transfer to. The user leaves the app for their banking app; Linq delivers the crypto on confirmation.

expiresAt is load-bearing for the UI. The order is dead after it and the customer must start again — so a screen that shows bank details without a countdown will send someone to their banking app to transfer into an account that has stopped accepting the payment.

Coin mapping is exhaustive and asymmetric — this is Linq's shape, not ours:

coin === "xlm"  ? { xlm: true }
coin === "usdc" ? { stellar: true }
                : unsupportedCoin(coin)   // never falls through

5. Read status

GET /ngn/onramp/orders/:orderId?customerRef=…

Both identifiers are required. See below.


Bills — crypto out of the wallet, airtime in

This is the better of the two flows and the reason it shipped first: the onramp makes the user leave the app to make a bank transfer, whereas a bill's deposit is crypto, from a wallet we already hold the signer for. The whole thing happens in one place.

POST /ngn/bills
{ customerRef, billCategory, provider, customerId,
  amountNgn, amountStableCoin, rate, coin, refundAddress }
→ 200 { id, customerRef, wallet, status }

wallet is the Stellar address the user sends the crypto to. Nothing is vended until the deposit arrives — this call only reserves the order and locks the rate.

Categories: airtime, data, electricity, cabletv, betting. Airtime ships first because it needs only a phone number and an amount; every other category carries extra fields (a data plan, a meter type) worth their own validation.

The two traps in this API

1. xlm is omitted, never false. Linq's docs are explicit: for USDC you drop xlm entirely rather than setting it to false. Bills use a bare top-level xlm: true, not the coin: { … } object the onramp uses.

export function buildCoinFields(coin: BillCoin): { xlm?: true } {
  return coin === "xlm" ? { xlm: true } : {};
}

It returns a spreadable object so a caller cannot accidentally write xlm: false, which is not how this API is told "use USDC". A test asserts the key is absent, not merely falsy.

2. refundAddress is required and is not decorative. It is where the deposit goes if the biller rejects the top-up after the user has already paid — the biller failing does not un-spend their XLM. It is validated before the request, because a missing refund address is otherwise only discovered at the moment the user's money is already gone.

Which rate a bill is priced at

Linq has two rates, about 7% apart: a buy side (/onramprate) and a sell side (/b2b/rate). A bill is a sale — crypto in, naira spent — but every bill was priced from the buy side, because the clients read GET /ngn/onramp/rate for it.

On 2026-11-11 Linq refused a USDC airtime order priced that way:

400  AmountStableCoin and rate do not match the current market rate

The sides stood at 1,480.71 and 1,380.71 that morning. So GET /ngn/onramp/rate now answers coin=usdc from the sell side, and the price guard on POST /ngn/bills measures against the same number.

Coin Side Why
USDC sell (/b2b/rate) A USDC bill at the buy rate is refused.
XLM buy (/onramprate) XLM bills priced this way are accepted and vend. Unchanged.

Two things here are not known, and are written down so nobody mistakes them for facts:

  • That the sell side is what Linq checks a USDC bill against is inferred, from the refusal above and from cash-outs settling at it. Linq has not confirmed it.
  • Whether Linq checks an XLM bill's rate at all. If it does not, and settles an XLM deposit at its own sell-side value, an XLM bill may be vending slightly less than the amount on screen. Nobody has compared the airtime received with the amount asked for.

It is fixed on the rate route, and not by re-pricing the order on the server, because the phone computes how much to send from the rate it was given. The deposit is whatever the phone decided; see the next section for what Linq does with a deposit that does not match the order.

A refused order is not free. Linq starts a cooldown on order creation after any attempt, including one it refused, and answers the next one with 429 Rate limit exceeded: Please wait before creating another order. A user who retries a refusal sees "busy" instead of the reason.

Manual deposit

Linq settles at whatever amount actually arrives, converted at the locked rate — "this is a manual deposit by design". So the amount we send must be the amount we quoted. An underpayment is not a failed order, it is a smaller bill than the user asked for.


The order record, and what it deliberately is not

The offramp persists its orders because it mints its own publicId and a bearer token, so a client can read an order back without the provider's id ever leaving the server. /ngn has a table too — NgnOrder — but it exists for a different reason and grants no new read access.

It exists because a bill's outcome is off-chain. When Linq delivers XLM or USDC for an onramp, the indexer sees the payment arrive at the wallet's classic account and the existing address subscription already pushes it; the onramp never needed a row to be observable. A biller vending airtime — or refusing to, after the user has already paid — is invisible to the chain. Without a row, order.failed arrives for an order we cannot identify, and the only thing that ever learns about the failure is a client that happens to poll.

Reads are unchanged. Linq's status endpoints require both customerRef and orderId, and return the same 404 for another customer's order as for one that does not exist. orderId is an unguessable UUID, so knowing someone's wallet address — which is semi-public — buys nothing on its own. findNgnOrder enforces the same thing locally: it takes both, matches the customerRef rather than merely accepting it, and returns null for a row belonging to someone else, which is indistinguishable from one that is absent.

Two things are therefore deliberately missing, and both should stay missing:

There is no index on customerRef, so nothing makes it cheap to list a customer's orders — and no route may list orders by customerRef alone. That would turn a public identifier into a way to read someone's bank details and amounts, and it is the one change that silently removes the protection.

customerId is not stored. The phone number for airtime, the meter number for electricity — it is passed straight through to Linq at creation and kept nowhere, for the same reason the NIN never is.

Lookups are rate-limited with skipSuccessfulRequests, so a wallet polling its own open order never spends the budget while someone trying ids they do not own exhausts it quickly: 20 failures per minute.

Falling back when Linq is down

If Linq answers 5xx or times out, a status read is served from the record instead of failing, marked stale: true with a reason. A 4xx is passed through unchanged — that is Linq telling us something true about the request (an unverified customer, an order that is not yours), and papering over it with a last-known row would turn "this order is not yours" into a status page.

Recording happens after the response is sent and never throws. The user is holding bank details or a deposit address they need; our bookkeeping must not stand between them and that. If the write fails, it is logged loudly and the rail degrades to exactly the behaviour it had before the table existed.

Webhooks

POST /webhooks/linq verifies Linq's signature over the raw body.

It must be mounted before express.json(). Linq signs the raw bytes, so any middleware that parses and re-serialises first destroys the ability to verify — the bytes change even when the value does not. wraith applies express.json() globally, which is exactly the trap Linq's own docs warn about, so this router brings its own express.raw() and is mounted ahead of it.

Events: order.processing, order.completed, order.failed.

One endpoint carries all three rails, and the body does not say which rail an event belongs to — only an orderId. So reconciliation is "find the order that id belongs to": the offramp table first, then NgnOrder. An id in neither is logged and ignored; nothing here creates a row, because an event for an order we never placed is not ours to act on.

Reconciliation is idempotent per event — it writes settled figures to a row keyed on the order id, so a redelivery writes the same values rather than double-counting. It is not order-safe: a late redelivery of an earlier event wins until the next status poll (idempotency.md). The 200 is sent before reconciling, because Linq times out at 10 seconds and marks the delivery failed, and a database write on a throttled instance can outlast that.


Environment

Variable Required Purpose
LINQ_API_KEY yes Business key. Its absence is what makes /ngn answer 503.
LINQ_BASE_URL no Overrides the default Linq host.
LINQ_WEBHOOK_SECRET for webhooks HMAC secret for x-linq-signature.
NETWORKS yes Must include mainnet or none of this answers.
SOROBAN_RPC_URL_MAINNET for indexing Not needed by these rails — they are plain HTTP to Linq. But listing mainnet in NETWORKS turns on the mainnet indexer, which does need it. Without it the indexer skips mainnet and says so; the API, and everything here, keeps serving.
OFFRAMP_LOOKUP_MAX_FAILURES no (10) Offramp failed lookups per window.
OFFRAMP_LOOKUP_WINDOW_MS no (900000) Offramp lookup window.
OFFRAMP_MAX_AMOUNT_NGN no (10000000) Largest amountNGN an offramp order may carry.
OFFRAMP_MAX_AMOUNT_STABLECOIN no (10000) Largest amountStableCoin an offramp order may carry.

Schema convergence

src/index.ts runs prisma db push at boot — without --accept-data-loss. With that flag, any change Prisma read as destructive applied silently on the next deploy, taking the column and everything in it. This database holds offramp orders: records of real naira paid to real bank accounts. A deploy that refuses to start is a problem someone fixes in minutes; a column of payment records that vanished during a routine deploy is not recoverable. Additive changes still apply on their own.

What is not built

Gap Consequence
Nothing pushes a bill result to the client The webhook now records a completed or failed bill, but no subscription carries it to the wallet, so the client still has to ask. The onramp does not share this gap — its delivery is an on-chain payment the indexer already pushes.
Bills ship airtime only in the UI The other four categories are accepted by the API but carry unvalidated extra fields (a data plan, a meter type).
Mobile screens Designed, not built.

Tests

src/__tests__/linq/bills.test.ts     the coin trap, refundAddress, auth header
src/__tests__/routes/ngn.test.ts     mainnet gate, C-address refusal, NIN absence
src/__tests__/routes/railErrorShape.test.ts     one { error, code } per rail, and nothing leaking (#287)
src/__tests__/linq/idempotency.test.ts     what a retry repeats, per provider call
src/__tests__/routes/idempotency.test.ts   the same request twice, per route
src/__tests__/fakes/linqProvider.ts        the fake provider those two run against

The route tests need NETWORKS=testnet,mainnet in the environment, because networkMiddleware refuses a network the deployment has not enabled — the same way a real deployment does.