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
14 changes: 7 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ POLL_INTERVAL_MS=6000
# ─── SAC Contract IDs ────────────────────────────────────────
# Comma-separated list of Soroban SAC (Stellar Asset Contract) IDs to watch.
# When unset, defaults to the native XLM SAC for the configured STELLAR_NETWORK:
# testnet → CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4
# mainnet → CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# testnet → CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# mainnet → CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
#
# How to derive a SAC address for any Stellar asset:
# import { Asset, Networks } from '@stellar/stellar-sdk';
Expand All @@ -57,15 +57,15 @@ POLL_INTERVAL_MS=6000
# .contractId(Networks.PUBLIC);
#
# Known mainnet SAC addresses:
# XLM (native): CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# USDC (Centre): CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA
# XLM (native): CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
# USDC (Centre): CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
#
# Example (watch XLM + USDC on mainnet):
# SAC_CONTRACT_IDS=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC,CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA
# SAC_CONTRACT_IDS=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA,CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75
#
# Per-network watch lists (for dual-network deployments when NETWORKS=testnet,mainnet):
# SAC_CONTRACT_IDS_TESTNET=CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4
# SAC_CONTRACT_IDS_MAINNET=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# SAC_CONTRACT_IDS_TESTNET=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# SAC_CONTRACT_IDS_MAINNET=CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA
#
# Soroban RPC's getEvents accepts multiple contract IDs in a single request —
# there is no N+1 query penalty for watching multiple contracts.
Expand Down
98 changes: 94 additions & 4 deletions clients/react-query/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2452,6 +2452,92 @@ export interface paths {
patch?: never;
trace?: never;
};
"/offramp/availability": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Is cash-out enabled on this deployment
* @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client). Answers the availability question without pricing anything: it makes no provider call, so it costs none of the shared rate allowance.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @enum {boolean} */
available: true;
};
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
};
};
/** @description Offramp not configured on this deployment */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
error: string;
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/offramp/rate": {
parameters: {
query?: never;
Expand All @@ -2460,12 +2546,15 @@ export interface paths {
cookie?: never;
};
/**
* Indicative NGN/USDC rate
* Indicative naira-per-coin rate
* @description Internal endpoint. Not part of the public data API: (provider callback / wallet-only cash-out surface served on behalf of the Veil wallet client).
*/
get: {
parameters: {
query?: never;
query?: {
/** @description Asset to price. Send it: without one the answer is priced for `usdc`, which is naira per dollar and wrong for every other asset by that asset's price. Omitting it is deprecated and will be refused once the installed wallet builds ask for a coin; a response always names the coin it was priced for. */
coin?: "xlm" | "usdc";
};
header?: never;
path?: never;
cookie?: never;
Expand All @@ -2479,9 +2568,10 @@ export interface paths {
};
content: {
"application/json": {
/** @description The asset `rate` is quoted per unit of. Always present, including for a request that named none — which is priced as `usdc`. */
coin: "xlm" | "usdc";
/** @description Naira per one unit of the requested coin, not naira per dollar. */
rate: number;
currency: string;
coin: string;
/** @enum {boolean} */
indicative: true;
};
Expand Down
105 changes: 105 additions & 0 deletions docs/offramp-orders.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,111 @@ a **new** `accessToken`; the previous token stops working. A different
`walletAddress` gets `409` and nothing about the order. Idempotency keys should
therefore be random and generated by the client.

## GET /offramp/availability — the gate

```
GET /offramp/availability → 200 { available: true } cash-out is enabled here
503 { error: … } this deployment has no payout key
```

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
GET /offramp/rate?coin= → 400
```

`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`](#get-offrampavailability--the-gate)
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`](https://github.com/Miracle656/veil), and stage 2 is
[veil#1124](https://github.com/Miracle656/veil/pull/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.

## Upgrading

- Orders created before this change have no `id` or token and cannot be read
Expand Down
Loading
Loading