An orderbook exchange for stablecoin FX.
Renders the USDC/cNGN spot market through an orderbook UI, with off/on ramping via Busha and Coinbase APIs for instant USD/USDC and NGN/cNGN conversions. Integrated with markets-service for live books and trades.
The app renders spot only. The futures section — a second terminal behind a sidebar rail, its
order ticket, margin/position hooks and order-submission envelope — was removed; markets-service
still serves the deliverable futures documented below, but nothing in this app reads them.
The app reads the live spot market from markets-service and renders it through the orderbook UI.
There is no mock, preview or sample data anywhere in the render path. Every price, level, trade,
candle and balance on screen is the venue's own or the connected wallet's. When markets-service is
unreachable or serves no spot market, the panels render empty states ("No resting bids", "No chart
data", a — price) rather than invented depth — a trader cannot tell fabricated depth from real
depth, and the prices would be ones nothing can fill at. lib/spot-market.test.mjs pins that
contract.
Set:
MARKETS_SERVICE_URL
Local development against the live venue (what .env.local ships with):
MARKETS_SERVICE_URL=https://api.numofx.comAgainst a local markets-service:
MARKETS_SERVICE_URL=http://127.0.0.1:8080Production must override that local default:
MARKETS_SERVICE_URL=https://api.numofx.comapi.numofx.com is the stable public hostname for markets-service; it is a CNAME onto the Railway
deployment, which also still answers on markets-service-production.up.railway.app. Prefer the
api.numofx.com name everywhere — the Railway hostname is a fallback and should not be handed to
external consumers.
Do not deploy the frontend with MARKETS_SERVICE_URL=http://127.0.0.1:8080.
In production, MARKETS_SERVICE_URL must point at the live markets-service deployment. The frontend throws at request time if NODE_ENV=production and the URL is missing or points at localhost.
The frontend is deployed on Vercel; MARKETS_SERVICE_URL is encoded in that project's production
environment and should be treated as required production configuration rather than tribal knowledge.
The spot order-book panel streams live depth and trades from markets-service over its WebSocket API (GET /v1/ws). The browser connects directly to the socket (no Next.js proxy), so the URL must be client-reachable:
NEXT_PUBLIC_MARKETS_WS_URL— e.g.wss://api.numofx.com/v1/wsin production,ws://127.0.0.1:8080/v1/wslocally.
The client subscribes to the public book and trades channels for the USDCcNGN-SPOT symbol, seeds from the snapshot frame, and applies update deltas. Both channels are unauthenticated; the only server-side gate is WS_ALLOWED_ORIGINS on the markets-service deployment, which must include the frontend origin or the browser handshake is rejected. When the socket is unreachable, still connecting, empty or crossed, the panel falls back to the server-rendered REST snapshot — also real venue data, just fetched at page render. A genuinely one-sided live book is shown as it rests rather than being replaced by the older snapshot. When neither source has depth, the ladder says so. That fallback is silent — there is no on-screen indicator of which source is rendering, so a stream that never goes live looks identical to a healthy one. (A "Live liquidity" badge used to signal this and was removed in bf5688e; its absence is what let the spot stream sit permanently in fallback, fixed in c7c2f2e.)
GET /api/strails/egress remains as an ops diagnostic that reports the deployment's current egress IP (used when registering an IP allowlist upstream).
markets-service has no seeding script, admin endpoint, or on-chain auto-discovery. Its market
list is a static registry in Go — services/markets/internal/instruments/registry.go in the
numofx/exchange monorepo — defining spot plus three USDC/cNGN deliverable futures. Each market is
served from GET /v1/markets only when its env var(s) are set on the markets-service deployment:
| Market | Expiry (UTC) | Env vars on markets-service | Live today |
|---|---|---|---|
USDCcNGN-SPOT |
— | CNGN_SPOT_ASSET_ADDRESS |
yes |
USDCcNGN-SEP16-2026 |
2026-09-16 14:00 | CNGN_SEP16_2026_FUTURE_ASSET_ADDRESS + CNGN_SEP16_2026_FUTURE_SUB_ID |
yes |
USDCcNGN-NOV30-2026 |
2026-11-30 00:00 | CNGN_NOV30_2026_FUTURE_ASSET_ADDRESS + CNGN_NOV30_2026_FUTURE_SUB_ID |
no |
USDCcNGN-MAY31-2027 |
2027-05-31 00:00 | CNGN_MAY31_2027_FUTURE_ASSET_ADDRESS + CNGN_MAY31_2027_FUTURE_SUB_ID |
no |
The address/sub-id pairs identify the instrument in the on-chain Matching contract. A market whose
pair is unset is simply absent from /v1/markets — that is the only reason a registry entry does
not appear, so an empty [] means none of the pairs are configured on the backend deployment.
Verify the "live today" column against
GET https://api.numofx.com/v1/marketsbefore relying on it. Until 2026-08-08 this table described aCNGN_JUN30_2026_*pair that no longer exists in the registry — it dated from the standalonemarkets-servicerepo and was never updated when the service moved intonumofx/exchange, so it sent readers looking for env vars matching nothing.
The frontend picks its one market out of that list with getLiveSpotMarket (lib/markets-service.ts),
taking the first entry whose contract_type is spot, base_asset_symbol is USDC and
quote_asset_symbol is cNGN. The futures rows are ignored — the futures filter
(getLiveDeliverableFXFutures) was deleted along with the futures UI.
Spot is live again. markets-service serves USDCcNGN-SPOT (contract_type=spot,
order_entry_spec=usdc_cngn_spot_v1) from GET /v1/markets, gated on CNGN_SPOT_ASSET_ADDRESS
being set on that deployment. Depth, trades and candles are real, and the spot order translation
contract below is what the engine actually expects.
An earlier revision of this section said spot had been removed in
e75d513(May 2026). That was true at the time and is no longer — verify againstGET /v1/marketsbefore trusting it again.
Legacy override envs: NEXT_PUBLIC_USDCCNGN_APR_FUTURE_ASSET_ADDRESS / NEXT_PUBLIC_USDCCNGN_APR_FUTURE_SUB_ID are no longer read at all — the code that applied them went with the futures UI. Leave them unset.
The app runs on Base mainnet (8453) — the chain the live venue settles on. getAppChain
returns mainnet unless NEXT_PUBLIC_MATCHING_CHAIN_ID=84532 asks for Sepolia by name; an unset or
malformed value lands on mainnet rather than quietly on a testnet whose contracts do not exist here.
NEXT_PUBLIC_BASE_RPC_URL must be set to the venue's keyed Alchemy endpoint on every deployment.
The code falls back to the public https://mainnet.base.org only so a missing env does not hard-fail
locally — that endpoint is shared and aggressively throttled, and a rate limit there is what made
wallet balance reads silently come back empty. Because the var is NEXT_PUBLIC_, the key ships in
the browser bundle: keep the Alchemy key domain-restricted, and rotate it there rather than in code.
Defaults live in MATCHING_STACK (lib/subaccount-deposit-config.ts) and
lib/spot-order-submission.ts; the envs below only override them.
| Env | Mainnet address | What it is |
|---|---|---|
NEXT_PUBLIC_MATCHING_ADDRESS |
0x9E90A9cD13d859Bd6a08168082FB1F6F7405F191 |
Matching — EIP-712 domain, DepositedSubAccount source |
NEXT_PUBLIC_TRADE_MODULE_ADDRESS |
0x44813aD30b2fFC1bB2871Eed9b19F63c8196eD1c |
the one module the venue's trades are submitted through |
NEXT_PUBLIC_SUBACCOUNT_CREATOR_ADDRESS |
0x568890A8D63Ba8a03b6eCbEedA1bD9f6ea014D5D |
periphery for createAndDepositSubAccount |
NEXT_PUBLIC_USDCCNGN_MANAGER_ADDRESS |
0xcE01f3D74400caE39bd7608cd2d286C2e3874d49 |
manager of every live account from #4 on |
NEXT_PUBLIC_SUBACCOUNTS_ADDRESS |
0x7019244E25FA416e6Ca2ed2F3cA25277aef72843 |
SubAccounts ERC-721 ledger |
NEXT_PUBLIC_WRAPPED_USDC_ASSET_ADDRESS |
0x6B232A2155Bd0C9bf741dB4cf8E7e8A0176A6fc6 |
CashAsset — the USDC deposit escrow |
NEXT_PUBLIC_USDC_TOKEN_ADDRESS |
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
canonical Base USDC, 6 decimals |
Each was verified against Base 8453 rather than copied: Matching emits the ModuleAllowed and
DepositedSubAccount events this app decodes, the creator answers to
createAndDepositSubAccount(address,uint256,address) and points back at that same Matching and
SubAccounts pair, and CashAsset.wrappedAsset() returns the USDC token above.
The stack moves as one. None of the Sepolia addresses have code on mainnet, so a half-flipped config is not a degraded app — it builds transactions against contracts that do not exist.
lib/subaccount-deposit-config.test.mjspins both stacks and the mainnet default.
Reachable only with NEXT_PUBLIC_MATCHING_CHAIN_ID=84532:
NEXT_PUBLIC_MATCHING_ADDRESS=0x1599636347FD5bA1fBE21D58AfE0b8B9cbe283FFNEXT_PUBLIC_TRADE_MODULE_ADDRESS=0x0AAE65AaA66Fe7f54486cDbD007956d3De611990NEXT_PUBLIC_USDCCNGN_MANAGER_ADDRESS=0x1917960763BF3a0DfA10a05f0a112E828C1A934fNEXT_PUBLIC_WRAPPED_USDC_ASSET_ADDRESS=0xdC3f31B61a2128B3D1ECB8b6f6d0DE82eBd6c7AeNEXT_PUBLIC_USDC_TOKEN_ADDRESS=0x8b3C43D2b2555ca3fc4Fa1BC34544133B8576110
Deposit flow address semantics (naming follows the risk-core deployment artifacts and is easy to invert):
-
NEXT_PUBLIC_WRAPPED_USDC_ASSET_ADDRESSis theWLWrappedERC20Assetcontract (baseinrisk-core/deployments/*/WRAPPED_USDC_DELIVERABLE.json). It receives deposits and is the ERC-20 spender for deposits to an existing subaccount. -
NEXT_PUBLIC_USDC_TOKEN_ADDRESSis the underlying USDC ERC-20 pulled from the wallet (wrappedAssetin the same artifact). The legacyNEXT_PUBLIC_USDC_DELIVERABLE_BASE_ASSET_ADDRESSenv is honored as a fallback alias for the token address. -
The cNGN pair (
NEXT_PUBLIC_CNGN_ASSET_ADDRESS+NEXT_PUBLIC_CNGN_TOKEN_ADDRESS) mirrorsrisk-core/deployments/<chainId>/WRAPPED_CNGN.json, whosebaseis the escrow andwrappedAssetthe token. The asset is the cNGN counterpart toNEXT_PUBLIC_WRAPPED_USDC_ASSET_ADDRESS— what a cNGN deposit approves and pays into, and the id labeling the cNGN leg of a subaccount balance. The token is the ERC-20 pulled from the wallet, which the spot terminal's Assets tab reads. Defaults per chain:Chain Asset (escrow) Token (ERC-20) Token decimals Base mainnet (8453) 0x9d806fd040a719d27a8e5e77dc5ae0ed1e0894930x46C85152bFe9f96829aA94755D9f915F9B10EF5F6 Base Sepolia (84532) 0x1c08f30c204EE18EbBDc161c0f0864AFb826934b0x6B232A2155Bd0C9bf741dB4cf8E7e8A0176A6fc618 Both escrows are verified on-chain:
wrappedAsset()returns the paired token,deposit(uint256,uint256)is present, and neither has awlEnabled()gate. The mainnet escrow is also the spot market'sasset_addressfromGET /v1/markets, so cNGN deposits and cNGN orders settle against one contract.Override the two together or not at all. An escrow only accepts the exact ERC-20 it wraps. Base Sepolia also hosts
0xe2387F04d3858e7Cb64Ef5Ed6617f9B2fcEEAfa2— likewise namedcNGN, but 6 decimals and not wrapped by this venue's escrow. The app pointed at it until 2026-08-11; approving it produces a deposit that cannot settle. Note the decimals differ by chain, so never hardcode 6: the deposit flow reads them from the token contract. -
Deposits may be whitelist-gated on-chain (
WLWrappedERC20Asset.wlEnabled). The app probes for the whitelist at preflight: plainWrappedERC20Assetdeployments (including the current Base Sepolia one) have no gate and deposits are open; on WL deployments only operator-whitelisted subaccounts can deposit, and the create-and-deposit path cannot activate.
For spot USDC/cNGN, the trader-facing API contract is intentionally different from the raw engine order.
- UI price:
cNGN per USDC - UI size:
USDC notional - UI
BUY: acquire USDC - UI
SELL: dispose of USDC
The engine still trades WRAPPED_CNGN against internal USDC cash, so the app must translate:
engine_price = 1 / ui_price
engine_amount = ui_size * ui_price
UI BUY -> engine SELL
UI SELL -> engine BUY
Fill deltas should reconcile as:
UI BUY -> dUSDC = +ui_size, d cNGN = -(ui_size * ui_price)
UI SELL -> dUSDC = -ui_size, d cNGN = +(ui_size * ui_price)