Skip to content

Latest commit

 

History

History
200 lines (157 loc) · 10.3 KB

offramp-orders.md

File metadata and controls

200 lines (157 loc) · 10.3 KB

Offramp order access

An offramp order carries a bank payout amount, a deposit address and a rate, so reading one back requires proof of having created it.

Creating an order

POST /offramp/orders returns two values the client must keep:

Field Example Notes
id ofr_ + 22 URL-safe characters (128 bits) The id to look the order up by. Not the provider's id, which is never returned.
accessToken oft_ + 43 URL-safe characters (256 bits) Bearer credential. Shown once; only its SHA-256 is stored.

The response is sent with Cache-Control: no-store.

Send either amountNGN or amountStableCoin, as a JSON number or a plain decimal string. Anything else ("abc", a negative or zero amount, "Infinity", a boolean, an array) is refused with 400 before any lookup or provider call. Each currency has its own ceiling:

Variable Default Purpose
OFFRAMP_MAX_AMOUNT_NGN 10000000 Largest amountNGN accepted.
OFFRAMP_MAX_AMOUNT_STABLECOIN 10000 Largest amountStableCoin accepted.

A value that is not a positive number is ignored and the default applies.

Reading an order

curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://<host>/offramp/orders/$ORDER_ID
Status Meaning
200 The token belongs to this order.
401 No usable Authorization: Bearer header. Identical for every id. code: unauthorized
404 Unknown id, malformed id, or a token that does not belong to the order. These are deliberately indistinguishable. code: order_not_found
429 Too many failed lookups from this IP. code: veil_rate_limited

Only failed lookups count toward the limit, so polling your own order never consumes it. Defaults: 10 failures per 15 minutes per IP.

Variable Default Purpose
OFFRAMP_LOOKUP_MAX_FAILURES 10 Failed lookups allowed per window.
OFFRAMP_LOOKUP_WINDOW_MS 900000 Window length.

Retrying a creation

Replaying POST /offramp/orders with the same idempotencyKey and an identical request returns the same order (replayed: true) with the same id and a new accessToken; the previous token stops working. A replay only counts as identical when the wallet, the amount, bankAccount, bankCode, accountName and refundAddress all match:

Replay differs in Result
walletAddress 409, and nothing about the order.
the amount 409 (different request).
bank account, bank code, account name or refund address 409 (different order details).

All three carry code: idempotency_conflict; the prose says which difference tripled it. Every error on this rail answers { error, code } — the full code catalogue is in ngn-rails.md.

Idempotency keys should therefore be random and generated by the client.

Two simultaneous requests with the same key resolve the same way: one creates the order, the other is answered as the replay (200, replayed: true) rather than with an error, after the same wallet and request checks. Each replay issues a new token, so the token from the most recent response is the one that works.

The request is compared through a stored HMAC-SHA256 fingerprint keyed with LINQ_API_KEY, not by storing the bank details. Rotating that key means a retry of an order created before the rotation is refused with 409.

GET /offramp/availability — the gate

GET /offramp/availability  → 200 { available: true }    cash-out is enabled here
                             503 { error, code: "not_configured" }   no payout key here

This is what a client should call when it wants to know whether to show a cash-out CTA. It makes no provider call, so it spends none of the rate allowance every user shares, and it never returns a number a client might mistake for a price.

It exists because there was no other way to ask. isOfframpAvailable() in the mobile client called /offramp/rate and read any error as "cash-out is down", which meant a pricing endpoint was the health check for the feature — and any change to the rate contract, including the staged coin requirement below, was an outage for every build that gated on it.

Gate on the body, not only the status: a proxy error page, a redirect, or a captive portal can all arrive as a response, and a client that treats "got a reply" as "available" will offer cash-out and then fail on the first tap.

The answer is served with Cache-Control: no-store, so a 200 cached before a rollback cannot keep offering a tile whose every other route now 503s.

GET /offramp/rate — coin, and how it becomes required

GET /offramp/rate?coin=xlm   → 200 { coin: "xlm",  rate: <NGN per 1 XLM>,  indicative: true }
GET /offramp/rate?coin=usdc  → 200 { coin: "usdc", rate: <NGN per 1 USDC>, indicative: true }
GET /offramp/rate            → 200 { coin: "usdc", … }  + a deprecation log line
GET /offramp/rate?coin=doge  → 400  { error, code: "invalid_coin" }
GET /offramp/rate?coin=      → 400  { error, code: "invalid_coin" }

rate is naira per one unit of the coin named, never naira per dollar, and it comes from ngnPerCoin() — the same path the onramp quotes with, so the two rails cannot disagree. The response always carries the coin it was priced for.

Pricing a coin as dollars is the defect that cost real money on the onramp: ₦1,000 bought 0.6 XLM instead of 3.6, because a dollar rate was divided as though it were an XLM rate. So coin is on its way to being mandatory.

Why a missing coin is still answered

Because installed APKs cannot be updated from this repository. The mobile client asks isOfframpAvailable(), which calls this endpoint and reads any error as "cash-out is down" and hides the tile. A 400 on a coin-less request therefore does not tell anyone to pick a coin — it removes cash-out for every user on an older build, silently, and looks like an outage. #216 was this mistake, and the lesson recorded from it was: ship the client first.

Answering is not the silent defaulting this endpoint exists to prevent. A USDC is a dollar, so the USDC rate is the number a coin-less caller gets from today's deployment: unchanged for the callers that cannot change yet, wrong for XLM in exactly the way it is already wrong, and now labelled with the coin it is priced for so a client can see the difference.

The three stages

  1. Here. An unknown coin 400s — new behaviour, nothing depends on it. A missing coin is answered as usdc and logged as deprecated, once per User-Agent, so the log reads as a list of callers rather than a flood.
  2. Clients. Both wallet builds send coin on every rate call, and the availability check moves to GET /offramp/availability instead of borrowing the rate endpoint. The client work lives in the wallet repos, not here, and it is the half that actually fixes XLM: the server cannot update an installed APK.
  3. Then. When a build carrying stage 2 is actually released and the deprecation line has been quiet for a full release cycle, a missing coin 400s and logCoinlessRateRequest is deleted along with the compatibility default.

Stage 3 is a breaking change to a money-moving endpoint. Do not make it from the server side alone: it needs evidence from the log, not a judgement that enough time has passed.

Stage 2, in the wallet repos

Written up as the contract first; the clients now live in Miracle656/veil, and stage 2 is veil#1124. Four call sites, one rate and one availability probe per client:

  • call('/offramp/rate') becomes a call that sends coin. Each cash-out screen names the asset it moves — both of them are USDC-only today, so a per-screen constant is right, as long as the constant says which asset and sits on the screen that knows. A lib that hard-codes usdc on the caller's behalf is what made this invisible in the first place.
  • isOfframpAvailable() moves to /offramp/availability, and treats a non-200 or a body without available: true as unavailable. A 200 from an edge proxy parses to {}, and an unread "yes" offers a flow that fails on the first tap.
  • The cached availability flag keeps its key. A build holding a cached 0 from the rate-based probe needs no migration: the stored meaning is unchanged ("the rails answered"), and every launch runs a live probe that overwrites it. This was first written as needing a bump, which was wrong.
  • A screen showing a quote reads coin back off the response instead of assuming its request was honoured, and refuses a quote for anything else. That field is the only signal a build has that it is on the deprecated path — and ngn / rate decides how much asset a user sends, so a USDC number answering to xlm is not a rounding error.

A build that ships only the availability half stays compatible with stage 1 — that is the point of staging it this way.

A key is scoped to one network and never expires: once used, it belongs to that order for good. Generate a new one per order and resend it only to retry that order. A 500 on creation does not mean no order exists, so retry with the same key rather than a new one. The details are in idempotency.md.

Upgrading

  • Orders created before this change have no id or token and cannot be read through GET /offramp/orders/:orderId. Replaying their creation request (same key and wallet) issues them credentials.
  • The lookup response now reports source: "live" instead of "linq".
  • Two nullable columns were added to OfframpOrder (publicId, accessTokenHash); run npx prisma db push.
  • A third nullable column, requestHash, binds an idempotency key to the request that created the order (npx prisma db push adds it; the server also does so at boot). Orders created before it have none and are compared on amount alone.