Skip to content

Latest commit

 

History

298 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Trading App

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.

Runtime config

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.com

Against a local markets-service:

MARKETS_SERVICE_URL=http://127.0.0.1:8080

Production must override that local default:

MARKETS_SERVICE_URL=https://api.numofx.com

api.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.

Live order book stream

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/ws in production, ws://127.0.0.1:8080/v1/ws locally.

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).

How markets are populated

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/markets before relying on it. Until 2026-08-08 this table described a CNGN_JUN30_2026_* pair that no longer exists in the registry — it dated from the standalone markets-service repo and was never updated when the service moved into numofx/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 market status

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 against GET /v1/markets before 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.

Chain and execution

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.

RPC

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.

Matching stack (Base mainnet)

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.mjs pins both stacks and the mainnet default.

Base Sepolia (opt-in)

Reachable only with NEXT_PUBLIC_MATCHING_CHAIN_ID=84532:

  • NEXT_PUBLIC_MATCHING_ADDRESS=0x1599636347FD5bA1fBE21D58AfE0b8B9cbe283FF
  • NEXT_PUBLIC_TRADE_MODULE_ADDRESS=0x0AAE65AaA66Fe7f54486cDbD007956d3De611990
  • NEXT_PUBLIC_USDCCNGN_MANAGER_ADDRESS=0x1917960763BF3a0DfA10a05f0a112E828C1A934f
  • NEXT_PUBLIC_WRAPPED_USDC_ASSET_ADDRESS=0xdC3f31B61a2128B3D1ECB8b6f6d0DE82eBd6c7Ae
  • NEXT_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_ADDRESS is the WLWrappedERC20Asset contract (base in risk-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_ADDRESS is the underlying USDC ERC-20 pulled from the wallet (wrappedAsset in the same artifact). The legacy NEXT_PUBLIC_USDC_DELIVERABLE_BASE_ASSET_ADDRESS env is honored as a fallback alias for the token address.

  • The cNGN pair (NEXT_PUBLIC_CNGN_ASSET_ADDRESS + NEXT_PUBLIC_CNGN_TOKEN_ADDRESS) mirrors risk-core/deployments/<chainId>/WRAPPED_CNGN.json, whose base is the escrow and wrappedAsset the token. The asset is the cNGN counterpart to NEXT_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) 0x9d806fd040a719d27a8e5e77dc5ae0ed1e089493 0x46C85152bFe9f96829aA94755D9f915F9B10EF5F 6
    Base Sepolia (84532) 0x1c08f30c204EE18EbBDc161c0f0864AFb826934b 0x6B232A2155Bd0C9bf741dB4cf8E7e8A0176A6fc6 18

    Both escrows are verified on-chain: wrappedAsset() returns the paired token, deposit(uint256,uint256) is present, and neither has a wlEnabled() gate. The mainnet escrow is also the spot market's asset_address from GET /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 named cNGN, 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: plain WrappedERC20Asset deployments (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.

Spot Order Contract

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)

About

Trading terminal for orderbook.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages