Skip to content

fix(offramp): require coin on GET /offramp/rate and return naira per unit of that asset - #257

Open
Daveeee-tech wants to merge 17 commits into
Miracle656:mainfrom
Daveeee-tech:fix/offramp-rate-coin-param
Open

Daveeee-tech wants to merge 17 commits into
Miracle656:mainfrom
Daveeee-tech:fix/offramp-rate-coin-param

Conversation

@Daveeee-tech

@Daveeee-tech Daveeee-tech commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

closes #237

Summary

GET /offramp/rate took no coin and passed through whatever the provider's /b2b/rate returned. For USDSUI that is naira per dollar, which a client can mistake for naira per unit of any other asset. The onramp had the same defect and it cost real money (₦1,000 bought 0.6 XLM instead of 3.6).

This PR makes the offramp price the asset it was asked for, and names it in the answer. It does not cut off builds that are already installed — the requirement is staged, because a missing coin is not a caller asking to pick a coin, it is a phone running a build from before the parameter existed.

What changed

GET /offramp/rate — coin is validated: present-but-unknown is a 400 naming the accepted values. A missing coin is still answered, priced as usdc, and logged once per User-Agent as deprecated. usdc is the asset the endpoint returned before coin existed, so the coin-less answer is numerically what installed builds already read, and the log is what says it must not stay that way.

Response shape — currency is gone (it was always NGN, and the field was being read as the priced asset, which is the bug). coin names what the number is per unit of:

{ "coin": "xlm", "rate": 214.9, "indicative": true }

Still indicative: true — the binding rate is the one locked into the order, not this quote.

GET /offramp/availability — new, and the reason this PR is bigger than the rate handler. Cash-out gated on /offramp/rate and read any error as "no cash-out", which made a pricing endpoint the health check for a feature: a change to the rate contract was an outage for every build that gated on it, and a coin-less rate request could be answered as a failure of the rails. /availability answers the question the gate is actually asking, never contacts the provider, and returns 503 when the deployment has no LINQ_API_KEY.

Pricing — via the existing ngnPerCoin, shared with the onramp. No second price path, no new arithmetic.

The rollout, and where each stage stands

missing coin unknown coin
1 — this PR answered, priced usdc, logged deprecated 400
2 — veil#1124 clients send coin; the gate moves to /offramp/availability —
3 — after both builds ship 400 400

Stage 3 needs the released APKs to be out and logCoinlessRateRequest to have gone quiet in the production log for as long as it takes an install to update. Neither is true yet, so the coin-less branch stays.

Caller audit

/offramp/rate has two callers, not one: frontend/wallet/lib/offramp.ts (web) and frontend/mobile/lib/offramp.ts (Expo). Both are updated in veil#1124, which also moves both availability probes off the rate endpoint. cli/bin/wraith.js and the Python/Go SDKs do not touch the offramp routes.

Tests

  • coin required-shape cases: unknown coin 400s, missing coin answers as usdc, and the deprecation log fires once per User-Agent (test seam __resetCoinlessRateLog).
  • /offramp/availability answers without a provider call; 503 when the key is absent.
  • Integration suite green — Integration tests passed on runs 39138211341 and 39140948244.
  • Unit suite green on this branch's CI: the run has not executed (see the comment below), so it is unverified rather than passing.

All rate tests mock global.fetch. No test reaches Linq: the naira rails are mainnet-only with no sandbox.

CI work that came with this

None of these were caused by the offramp change; three of them were red on main first, and the branch inherits them because it touches openapi.json.

  • 2f82272 — the integration list cases still asserted the response shape from before perf(api): drop the default COUNT from list queries, add hasMore and opt-in includeTotal #218 dropped the default COUNT. Fixed by asking for the count (includeTotal=true) where a case asserts one, and asserting the page instead where it does not.
  • c5b5b36 — the dual-network harness never told /status it runs two chains, so it read back testnet only.
  • 81293b6 — clients/react-query/src/schema.d.ts was stale against openapi.json by three routes (/ngn/customers/status, /fees, and /status's per-network object). Red on main since /fees was documented. Regenerated from the generator's own diff rather than hand-written.
  • 40a3b42 — the one that matters: npm test was not slow, it was stuck. A case added on main calls startAllIndexers(["testnet","mainnet"]) to check which networks it would run, gets its answer, and leaves behind a loop that crashes on the absent database, schedules a restart 10s later, crashes again, forever. Jest waits on the process, so the run never ends and never reports; the suite finishes in 22 seconds. e746aa4 gave the step a deadline to make that readable, 40a3b42 fixes the cause by unref'ing the restart wait and the inter-poll pause (a crash-restart is not a reason to keep a process alive — the API listening is), and _resetIndexerLoops now cancels pending restarts. e746aa4's workflow edit is reverted in 3dd5691: a PR that changes a workflow file holds every later commit for approval, which would leave this fix unverified for a reason unrelated to naira rates. The deadline belongs in its own PR.

Notes for reviewers

  • Compare the coin validation against GET /onramp/rate; the error shape is intentionally identical.
  • The staged order is the point. If you would rather 400 a missing coin today, the cost is every installed build that has not updated, and the log line says what that count currently looks like.
  • Not fixed here, and not caused here: cli/bin/wraith.js:116 prints Showing ${result.transfers.length} of ${result.total} total transfers, which has read of undefined total since perf(api): drop the default COUNT from list queries, add hasMore and opt-in includeTotal #218 made total opt-in. No CI covers the CLI, so it is invisible to the suite. Happy to take it as a separate issue.
  • Nothing was executed locally: this machine has no node/npm, so every claim above about green is a CI run, and the one run that has not executed is called out as unverified.

…acle656#237)

GET /offramp/rate previously called Linq's /b2b/rate which always returns naira per dollar. Dividing a naira amount by that gives the right USDC figure by coincidence (1 USDC = 1 USD) but the wrong figure for any other asset by exactly that asset's price — the same defect that caused the onramp to buy 0.6 XLM instead of 3.6 for a 1000 NGN purchase.

Changes:

- src/api/ngn.ts: export ngnPerCoin so the offramp can reuse the same price path

- src/api/offramp.ts: require coin query param (400 without it), call ngnPerCoin(), cache per-coin, response is { coin, rate, indicative: true }

- src/__tests__/routes/offramp.test.ts: add 7 tests pinning XLM vs USDC rates differ, 400 on missing/unknown coin, no currency field, 502 on pricing failure

- src/openapi/schemas.ts: add offrampRateQuerySchema, update offrampRateResponseSchema (coin enum, rate description, drop currency)

- src/openapi/build.ts: add request.query to /offramp/rate registration, update summary

- openapi.json: regenerated to match new schema
@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@Daveeee-tech 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! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning is right and the money argument is right. But merging this alone takes cash-out away from every installed APK, and I want to make sure that is not discovered in production.

What happens on merge

Both clients call the endpoint with no coin:

frontend/wallet/lib/offramp.ts:221   await call<OfframpRate>('/offramp/rate')
frontend/wallet/lib/offramp.ts:242   return call<OfframpRate>('/offramp/rate')
frontend/mobile/lib/offramp.ts:149   await call<OfframpRate>('/offramp/rate');
frontend/mobile/lib/offramp.ts:179   return call<OfframpRate>('/offramp/rate');

And line 149 is inside isOfframpAvailable(), which treats any error as "the offramp is not available":

export async function isOfframpAvailable(): Promise<boolean> {
  try {
    await call<OfframpRate>('/offramp/rate');
    ...
  } catch {
    void AsyncStorage.setItem(AVAILABILITY_KEY, '0');
    return false;
  }
}

So a 400 on a coin-less request does not surface as "pick a coin". It makes the Cash out tile vanish from the app, for everyone, immediately on deploy — and it looks like the backend is down. The web wallet redeploys with main; installed APKs do not update on our schedule, so those users lose cash-out until they happen to upgrade.

We have been here before: #216 was a breaking offramp change and the lesson recorded from it was ship the client first.

What I would like instead

Keep the strictness, stage the rollout:

  1. Now: reject an unknown coin with a 400 — that is new behaviour nothing depends on. For a missing coin, keep answering as today, and add the coin the answer is priced for to the response body so a client can tell. Log it as deprecated so we can see who is still asking.
  2. Then: update both clients to send coin, including isOfframpAvailable() — which should probably probe with an explicit coin rather than relying on the rate call at all, since "is the service up" and "what is the rate for XLM" are different questions.
  3. Then, once the APK with that change is actually out, flip a missing coin to a 400 and delete the compatibility path.

I know step 1 looks like exactly what you argued against — "defaulting here would reproduce that silently". The difference is that it does not change anything: a coin-less caller gets the same answer it gets today, which is correct for USDC and wrong for XLM, and is already wrong in both clients right now. This PR cannot fix those clients; only shipping them can. Making the server refuse does not make an old APK compute XLM correctly, it just stops it computing at all.

What is good here and should survive

ngnPerCoin shared with the onramp so both rails quote from one source — that is what makes the two rails agree, and it is the actual fix for the class of bug.

The per-coin cache, so an XLM quote does not evict a USDC quote. The single-slot cache would have made the rate flap between assets under concurrent users, which is a second, subtler version of the same wrongness.

And the comment carries the evidence — ₦1,000 buying 0.6 XLM instead of 3.6. Writing the real number into the code is why the next person will not quietly undo this.

Split out the client changes, or restructure as above, and I will merge it quickly.

…lled APKs

A coin-less request to /offramp/rate is how every installed build asks for the
rate, and the mobile client reads any error there as "cash-out is down" and hides
the tile. Refusing it does not make an old APK price XLM correctly - it stops that
build pricing at all, for every user on it, until they happen to update. Miracle656#216 was
this mistake and the recorded lesson is ship the client first.

Defaulting for a missing coin is not the silent dollar-rate bug this endpoint was
built to kill: ngnPerCoin("usdc") *is* naira per dollar, so a coin-less caller
gets back exactly the number it gets today, and the response now names the coin it
was priced for so a client can tell. Making the server strict cannot fix clients
that are not in this repository; only shipping them can.

Changes:

- src/api/offramp.ts: an unknown or blank coin still 400s (new behaviour, nothing
  depends on it). A missing coin is priced for usdc and logged as deprecated, once
  per User-Agent, so the log lists the callers still asking rather than flooding.
  ngnPerCoin, the shared price path, and the per-coin cache are unchanged.

- src/openapi/schemas.ts: coin is optional in the query schema, with the
  deprecation and the switch-over condition in its description; the response's coin
  field says what it is quoted per. No OpenAPI `deprecated` flag - these routes are
  live, and src/__tests__/openapiCoverage.test.ts pins that.

- src/__tests__/routes/offramp.test.ts: the coin-less request now asserts the USDC
  answer, the coin it is labelled with, one deprecation log per caller, and 400 for
  unknown/blank/repeated coin. Explicit-coin cases are untouched.

- docs/offramp-orders.md: the three stages, and the gate on stage 3 - a build
  carrying the client change must be out and the log line quiet. Stage 2 (send
  coin, and stop using this endpoint as the availability probe) lives in the wallet
  repos, not here.

- openapi.json, clients/react-query/src/schema.d.ts: hand-updated to match, because
  this checkout has no node runtime or node_modules. Regenerate with
  `npm run docs:openapi` and `npm run generate -w clients/react-query` before
  merging; schema.d.ts was already stale from the previous commit.
…ps borrowing the rate endpoint

Stage 2's client change needs somewhere to ask "is cash-out up" that is not
"what is the rate for XLM". Until now the only way a client could find out was
/offramp/rate, and it read any error there as no-cash-out - which is why the coin
requirement had to be staged rather than enforced, and why a coin-less request is
the most reliable availability probe an old build has.

The new route answers from process config only and makes no provider call, so an
app opening on every install does not spend the rate allowance all users share.
It returns a body as well as a status because a redirect, a proxy error page, or a
captive portal is a reply, and a client that treats "got a reply" as "available"
offers cash-out and then fails on the first tap.

- src/api/offramp.ts: GET /availability -> { available: true }, no-store. The
  router's existing 503 when LINQ_API_KEY is unset is the negative answer, so
  nothing about deployment config is duplicated here.
- src/openapi/schemas.ts, build.ts: the response schema and the registration. No
  502 documented, because nothing here reaches the provider; 503 is documented
  because it is the answer a CTA is hidden on.
- src/__tests__/routes/offramp.test.ts: answers without touching the price path,
  no-store, and 503 without a key.
- docs/offramp-orders.md: the gate's contract, and stage 2 written out as the
  client repos have to satisfy it - including that a build holding a cached
  unavailable flag from the old rate-based probe stays wrong until that key is
  cleared.
- openapi.json, clients/react-query/src/schema.d.ts: hand-written again, same
  caveat as the previous commit - regenerate before merge.
…re wrong

Writing the client half found two claims in the rollout doc that the code does
not support.

The cached availability flag needed no migration. A build that cached `0` while
the rate call was 400ing stores "the rails did not answer", and every launch
overwrites it with a live probe — so the stale value self-heals rather than
needing a key bump.

And the coin each screen sends is a per-screen constant, because both cash-out
screens are USDC-only today. The rule was never "not a constant", it was "the
name sits where the asset is known" — a lib that hard-coded `usdc` on the
caller's behalf is what made the pricing bug invisible.
…racle656#218

Four failures, one cause. Miracle656#218 stopped running a COUNT for every list query and
made `total` opt-in through `includeTotal=true`, which is the right trade for an
endpoint on the hot path — but it updated the unit tests and the README and left
these three files asking `body.total` of a response that no longer computes one,
so the integration job has been red since.

The three cases that assert an exact count now ask for it, which is also what
tests the flag end to end. The one case in `e2e.test.ts` that only wanted
`total >= 1` does not need a COUNT at all: the two lines after it prove a
specific row came back, which is a stronger claim about the same response. The
incoming case gained the other half — without the flag, no `total`, and `hasMore`
says the page is complete.

`src/api.ts` said `{ total, limit, offset, transfers }` in three route
docstrings, which is the sentence that kept leading readers to the old shape.
"reports both networks with differing cursors on /status" got a `networks` object
with `testnet` in it and no `mainnet`.

`/status` lists the loops in this process and, finding none it can see, falls
back to `enabledNetworks()` — which with `NETWORKS` unset is `currentNetwork()`,
and with `STELLAR_NETWORK` unset that is testnet. The single key the response
had was the fallback answering, not the registry. The harness registers its loops
through `_createLoopForTesting` and the DB assertions around it pass, so why the
route did not see them through the module-level registry is still unanswered;
what is certain is that the assertion should not depend on which of the two
paths answered.

Setting `NETWORKS=testnet,mainnet` makes the harness describe the deployment it
is simulating, and then both routes to the answer agree. It also matches what a
real dual-chain process looks like: `NETWORKS` is what an operator sets, and
`?network=mainnet` is rejected by the same list, so a test that wants mainnet
reported has to declare mainnet enabled.
…ed on without

The SDK workflow fails when src/schema.d.ts differs from what
`npm run generate` reads out of openapi.json, and the committed copy had
fallen behind three routes: /ngn/customers/status and /fees were never
carried over, and /status gained the per-network object plus nullable
lag fields when the indexer stopped being a single loop.

So the gap reads as belonging to the offramp change it happens to sit
next to. It does not — the check has been red on main since the commit
that documented /fees, and this branch only inherits it by touching
openapi.json, which is the path filter that wakes the workflow up. The
one genuinely new line is the @enum on the rate reply's coin.

The file is generated, so the diff is the generator's own output rather
than a hand-written guess at it: no npm on this machine, so it came from
the log of the run that just failed, applied as a patch and checked with
git apply before it was staged.
…d of sulking

`npm test` used to finish in about 30 seconds, and did as recently as the run
before this one. Every job since has sat on that step with nothing to show for
it: the build job declared no timeout-minutes, so GitHub's six-hour default
applies, and a job that is still running has no downloadable log. A hang ends
up indistinguishable from CI being slow, both from outside and from in here.

Two deadlines, one at each level. The step gets 15 minutes with a KILL 30
seconds behind it, because a jest left holding an open handle may not answer
TERM, and a killed process flushes the output it already produced — which is
the point: the log names the suite that stuck. The job gets 20 minutes so a
step that ignores both still ends.

This does not fix the hang and is not pretending to. It turns a six-hour
non-answer into a fifteen-minute failure with a readable log, which is the
shortest path to the handle itself.
…ever exits

CI's unit step hung for hours on a suite that finishes in 22 seconds. The
deadline added in the previous commit turned that into a readable log, and the
log said: `Cannot log after tests are done`, once every 10 seconds, with
`[indexer/testnet] loop crashed, restarting in 10s` between each. A case calls
startAllIndexers to check which networks it would run, gets its answer, and
leaves behind a loop that crashes on the absent database, schedules a restart,
crashes again, and schedules another. That chain is what jest waits on, so the
run never ends and never reports. It is not this branch's case and not this
branch's timer — the call came in with the per-network status work on main, and
every branch that merges with it inherits the hang, which is why main has been
red since the morning too.

The restart wait is unref'd, along with the pause between two polls. Neither is
a reason to keep a process alive; the API listening is, and `main` always
starts one. Held to the event loop they are immortal in either direction: a loop
that keeps crashing restarts forever, and a healthy one polls forever.

`_resetIndexerLoops` now cancels pending restarts as well as dropping loop
state, so the seam that already exists for tidying between cases tidies the
timer too. That alone does not fix the run above — the restart is scheduled
after the last case ends, so there is no next beforeEach to catch it — which is
why the unref is the actual fix and this is the hygiene that goes with it.
The timeout did exactly what it was added to do: it ended a six-hour
non-answer in fifteen minutes and produced the log that named the crashed
indexer loop in the commit before this one. It has no further work here, and
keeping it costs more than it is worth inside a pull request about naira rates.

A pull request that changes a workflow file holds every later commit from the
same author for a collaborator to approve. That is the right rule — a fork
should not get to run its own edited CI unchecked — but it means the actual fix
in this branch sits unverified until someone clicks, for a reason that has
nothing to do with offramp rates. The deadline goes to its own pull request,
where it can be approved for what it is.
@Daveeee-tech

Copy link
Copy Markdown
Contributor Author

One thing for whoever picks this up: the unit suite cannot be verified on this branch until a run is approved, and the box in the description is unchecked because of that, not because it looks like failing.

What I found about the hang, since it is not this PR's bug:

npm test is not slow — the suite finishes in about 22 seconds — it is stuck. To read a stuck step you have to end it, so e746aa4 put timeout -k 30s 900s on the step and timeout-minutes: 20 on the job. The log that produced says, every ten seconds until the kill:

●  Cannot log after tests are done. Did you forget to wait for something async in your test?
  Attempted to log "[indexer/testnet] loop crashed, restarting in 10s: [TypeError: Cannot read
  properties of undefined (reading 'findUnique')]"
      at error (src/indexer.ts:595:15)

A case on main calls startAllIndexers(["testnet", "mainnet"]) to assert which networks it would run, gets its answer, and leaves a real testnet loop behind. It crashes on the absent DATABASE_URL, schedules a restart 10 seconds out, crashes again, and schedules another. That chain keeps the process alive, so jest never exits, so the job sits there until GitHub's six-hour default ends it — and a job that is still running has no downloadable log, which is how this took a day to name. 40a3b42 unrefs the restart wait and the inter-poll pause (a crash-restart is not a reason to hold a process open; the API listener is), and _resetIndexerLoops now cancels pending restarts as well as dropping loop state.

Three notes on the shape of it:

  • This is why main has been red since ~10:00 UTC today, and why every branch that merges with it hangs too. Eight jobs on that repo are still holding runners on this right now.
  • e746aa4's workflow edit is reverted by 3dd5691. A PR that changes a workflow file puts every later commit behind a collaborator's approval, so keeping the probe here would have left the actual fix unverified for a reason unrelated to naira rates. The deadline is worth landing on its own and I will open that separately.
  • The unref is the fix; clearing timers in _resetIndexerLoops is not, because the restart is scheduled after the last case ends and there is no following beforeEach to catch it.

The other two red checks were pre-existing and are explained in the description: the integration cases asserting the pre-#218 shape (2f82272, c5b5b36) and the React Query schema having drifted three routes behind openapi.json (81293b6, red on main since /fees was documented).

If you approve the held runs, Integration tests and React Query SDK are already known green and the only open question is whether the unit suite now exits.

Merging main into this branch kept both halves of `_resetIndexerLoops` but
dropped the `restartTimers` declaration it iterates, so nothing compiled:
typecheck, the Docker build behind the integration suite and the chaos image
all failed on the same four TS2304 errors.

Co-authored-by: Qoder <noreply@qoder.com>
Daveeee-tech and others added 4 commits October 18, 2026 17:39
All 816 unit tests pass and Jest still exits 1, because the loop started by the
Miracle656#238 case is still polling when the environment goes away: its next RPC call
lazily requires a module from a torn-down sandbox and throws after the summary
has printed. Before the restart timer was unref'd the process never got that
far — it hung instead — so this is the same abandoned loop, now visible.

`while (true)` becomes `while (loops.get(net) === loop)`. `_resetIndexerLoops`
already means "these loops are no longer wanted" to the callers that use it, so
let the loop agree; the last case in a file now also stops its own loops in an
`afterEach`, rather than leaving the next `beforeEach` to do it after Jest has
already torn everything down.

Co-authored-by: Qoder <noreply@qoder.com>
All 816 unit tests pass and Jest still exits 1, because the loop the Miracle656#238 case
starts outlives the suite: its pending work rejects once the mock database is
gone, the crash handler announces "restarting in 10s" after the summary has
printed, and Jest counts output after teardown as a failure. Unref'ing the
restart timer (40a3b42) made the process exit; this is what it now prints on
the way out.

Silence is only correct if the loop was dropped on purpose, so the request to
keep a network running is tracked separately from the loop's own state:
`_resetIndexerLoops` drops it, and a rejection that arrives afterwards ends the
loop instead of reporting and restarting it. In production nothing ever drops
it, so a crashed loop still gets its restart and its log.

Co-authored-by: Qoder <noreply@qoder.com>
Silencing the dropped loop (35d07d4) still left Jest exiting 1 with 816 tests
passing. The remaining output is the loop the Miracle656#238 case starts: its startup
awaits a database that does not exist in a unit run and a public testnet RPC,
so every line it prints — and the retry it fires after the first one fails —
lands after the suite has finished. Jest counts that as a failure, and a
require from inside that retry is the ReferenceError the earlier runs showed.

The case never wanted a loop. It asks which networks a half-configured set
yields and why the other is skipped, so that question now has its own function
and `startAllIndexers` is just the part that acts on it. A second case covers
what Miracle656#238 actually broke — a missing endpoint used to throw out of main() — and
it can call the starter safely, because with nothing runnable it starts nothing.

Co-authored-by: Qoder <noreply@qoder.com>
Now that the suite reports instead of hanging it can be seen failing: the first
balance case exceeds the 5s timeout. It is not the route. The stale-read
middleware (Miracle656#164) probes RPC health on every GET, and this was the one app-level
suite that left `../../rpc` real, so the first request went out to the public
testnet Soroban endpoint and waited for it — the rest of the file was fast
because the answer is cached for 3s, which made it look like a one-off.

The late `[api] Unhandled error: ... reading 'map'` in the same run is the tail
of that request arriving after the case had already been abandoned.

Co-authored-by: Qoder <noreply@qoder.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The offramp rate is naira per dollar and nothing says so

2 participants