@nexus-xyz/exchange-ts
v0.3.0
Published
Official TypeScript SDK for the Nexus Exchange API — a typed wrapper over the public REST + WebSocket API.
Downloads
2,438
Readme
nexus-exchange (TypeScript)
Official TypeScript SDK for the Nexus Exchange API — a typed wrapper over the public REST + WebSocket API, usable from the browser and Node.
⚠️ Experimental / in development. It is being extracted and sanitized out of the Nexus web app's existing bindings; the public surface lands incrementally. The typed request/response models, the public market-data REST client, the authenticated account/order endpoints, and the WebSocket streaming client have landed. For the ahead-of-this surface use the Rust SDK or the Python SDK.
Quick start
import { Client } from "@nexus-xyz/exchange-ts";
const client = new Client(); // defaults to the public testnet host, no credentials
for (const market of await client.fetchMarketSummaries()) {
console.log(market.market_id);
}
const ticker = await client.fetchTicker("BTC-USDX-PERP");
console.log(ticker.last, ticker.markPrice);No credentials are needed for market data. See
examples/public_market_data.ts. A Client
is stateless per request and safe to share across concurrent calls — each call
signs and assembles its own request, with no shared mutable state and no locks.
Market-data methods
fetchMarketSummaries, fetchTickers, fetchTicker, fetchOrderBook,
fetchTrades, fetchCandles, fetchFundingHistory, fetchFundingSamples,
fetchMarkPrice, fetchMarketStatus, fetchStats, and fetchStatsHistory —
covering the public market-data routes of the pinned spec. Each returns the
corresponding typed model.
Errors are a small hierarchy under NexusExchangeError: ApiError (non-2xx;
transient for 5xx/408), TransportError (connection/timeout/abort; always
transient), and MissingCredentialsError.
State-changing operations (orders, amends, deposits, margin moves, faucet) can
answer 403 from a jurisdiction control: an ApiError whose code is
RESTRICTED_JURISDICTION, US_RESTRICTED, or GEO_UNRESOLVED (the
JurisdictionError model; the same value as the x-nexus-block-reason header).
Match on code, never the message, and treat every reason — including one you do
not recognize — as permanent: transient is false, so retrying cannot help.
Redirects are never followed, because following one would leak a credential:
fetch's default forwards the X-Nexus-Key-Id / X-Nexus-Signature headers
across an origin change (it strips Authorization, but not custom headers), so a
signed request answered with a 301 would hand a valid HMAC signature to a host
that is not the API — while also dropping the body and turning the POST into a
GET. No operation in the spec answers 3xx, so a redirect only ever means the
path is not served at the configured baseUrl. The client stops at it and raises
a terminal ApiError naming the target instead — terminal, so a retry cannot send
further copies of the signature.
Authentication
Authenticated requests are signed with HMAC-SHA256 over a canonical string, byte-for-byte identical to the Rust and Python SDKs and to what the server verifies:
<timestamp_ms>\n<METHOD>\n<path>\n<query>\n<sha256hex(body)>The string is signed with the hex-decoded API secret and sent as three headers:
x-api-key, x-timestamp (Unix epoch ms), and x-signature (hex). An empty
query is the empty string; an empty body still contributes sha256hex("").
<path> is the logical request path the indexer verifies, including the
/api/v1 prefix (e.g. /api/v1/orders) but excluding whatever path the base
URL carries. The gateway strips its own /api/exchange prefix before the
indexer verifies, so a request sent to …/api/exchange/api/v1/orders is verified
as /api/v1/orders. Signing the wire pathname instead would cover bytes the
server never sees — see What baseUrl is.
import { Client, Network } from "@nexus-xyz/exchange-ts";
const client = new Client({
network: Network.Testnet, // play funds; the default
apiKey: process.env.NEXUS_EXCHANGE_API_KEY,
apiSecret: process.env.NEXUS_EXCHANGE_API_SECRET, // 32-byte hex from POST /keys
});
const account = await client.getAccount();
const { order } = await client.placeOrder({
market_id: "BTC-USDX-PERP",
side: "Buy",
order_type: "Limit",
price: "65000",
quantity: "0.1",
time_in_force: "GTC",
});
await client.cancelOrder(order.id);Credentials are optional — construct the client without them for public reads;
any signed endpoint then throws MissingCredentialsError. Implemented
authenticated endpoints: account (getAccount, getAccountSummary,
getAccountState, getAccountFees, getEquityHistory,
getPortfolioHistory, getRateLimit, claimCredit); funds (deposit,
createDeposit, getDeposits, getWithdrawals, claimFaucet, adjustMargin);
positions (getPositions, getClosedPositions); getFills; and orders —
placeOrder, placeOrderBatch, previewOrder, getOpenOrders,
getOrderHistory, amendOrder (PATCH, cancel-replace), cancelOrder,
cancelAllOrders.
Market-family orders can carry max_slippage_bps (spec v0.7.3), a server-enforced
cap: the engine pins the book mid at submission and holds the running fill VWAP
inside mid ± mid × bps / 10000, cancelling the unfilled remainder with a
SlippageCap reason on a normal success response rather than an error. Two traps
— 0 is not "no cap" (it collapses the band onto the mid, so the order cancels
with zero fills; omit the field instead), and a capped order needs both sides
of the book populated or it is rejected with InsufficientLiquidity. Ignored on
the limit family, and accepted-but-not-applied by previewOrder.
Portfolio
getAccountState returns the whole account in one call — the summary aggregates
plus every open position — built from a single coherent read, so
summary.open_positions_count always matches positions.length. Prefer it over
pairing getAccountSummary with getPositions.
const { summary, positions } = await client.getAccountState();
// `withdrawable` is free margin floored at zero: exactly what can leave the
// account, already net of initial margin and open-order reservations.
console.log(summary.withdrawable, positions.length);
// Per-position risk detail. Every derived field is nullable and carries a
// paired `*_error` reason — null means "not computed", never zero.
for (const p of positions) {
console.log(p.market_id, p.notional_value ?? p.notional_value_error);
console.log(p.roe ?? p.roe_error, p.margin_used, p.max_leverage);
// Paid-positive: > 0 means this position has paid funding.
console.log(p.funding_paid);
}The fields v0.7.2 added — withdrawable and the per-position risk detail — are
typed optional, because the schemas mark nothing as required and a server
older than v0.7.2 omits them outright. So each has three states, and they are
worth keeping apart: a value, null (reported but not computable — read the
paired *_error), or undefined (this server does not report the field at all).
?? fallback collapses the last two, which is usually what you want; reach for
=== undefined to tell an old server apart from a degraded field. Never coalesce
a missing withdrawable to "0" — "not reported" and "nothing withdrawable" are
different answers and only one of them is safe to act on.
getPortfolioHistory returns equity, cumulative trading PnL, and cumulative
traded volume over a window, oldest first. Omit window to take the server's
day default, and read window/cadence_ms off the response rather than
assuming what was served.
| window | cadence | max points | span |
| ------- | ------- | ---------- | ---- |
| day | 5 min | 288 | 24 h |
| week | 1 h | 168 | 7 d |
| month | 6 h | 120 | 30 d |
| all | 1 d | 366 | ~1 y |
limit is optional and bounded by the spec to an integer in [1, 366]; the SDK
rejects anything else with a RangeError before signing, rather than spending a
round trip on a guaranteed 400. Within range the server clamps further to the
window's capacity above, so asking for more points than a window holds is fine.
const history = await client.getPortfolioHistory({ window: "week" });
for (const p of history.points) {
// Decimal strings — parse with a decimal type, never a float. (Note
// `EquityPoint.equity` from `getEquityHistory` is a JSON number instead.)
console.log(p.timestamp_ms, p.equity, p.pnl, p.volume);
}getAccountFees reports the effective fee schedule. maker_fee_bps may be
negative — that's a rebate, not an error — and tier / schedule are open
strings that will gain values when the fee model lands, so don't switch
exhaustively on them.
const fees = await client.getAccountFees();
console.log(fees.maker_fee_bps, fees.taker_fee_bps, fees.tier, fees.schedule);
// True when the rolling window may undercount (source fill buffer was full).
console.log(fees.volume_30d, fees.volume_30d_estimated);Networks
The public axis is testnet (play funds) vs mainnet (real funds).
Network.Local is a developer convenience, not a public network. The network is
carried in the host, not the path, and each one is its own origin terminating
its own TLS and WebSocket upgrades.
| Network | Funds | Faucet | REST base | WebSocket base |
| --------------------------- | -------- | ------ | ----------------------------------------- | -------------------------- |
| Network.Testnet (default) | play | yes | https://exchange.nexus.xyz/api/exchange | wss://exchange.nexus.xyz |
| Network.Mainnet | real | no | not live yet — see below | — |
| Network.Local | play | yes | http://localhost:9090 | ws://localhost:9090 |
networkConfig(network) returns the bundled config (label, funds, faucet, base
URLs, signing domain); NETWORKS is the whole frozen map. Anywhere a Network
member is accepted, a customNetwork() descriptor is too — see
Custom networks.
const client = new Client({ network: Network.Testnet });
client.network; // Network.Testnet (or the descriptor, for a custom target)
client.label; // "Testnet"
client.funds; // "play" — gate money-moving actions on this, not on the name
client.isRealFunds; // false — true for "real" *and* "unknown" (it fails closed)
client.hasFaucet; // true
client.baseUrl; // "https://exchange.nexus.xyz/api/exchange"
client.wsUrl; // "wss://exchange.nexus.xyz" — hand to createWsClient({ url })funds is a tri-state — "play" | "real" | "unknown" — because a boolean
has no honest value for a target that never declared what it moves. Match
"play" positively so "unknown" fails closed:
if (client.funds === "play") fund(); // correct — "unknown" is refused
if (client.funds !== "real") fund(); // WRONG — "unknown" passes as safe[!IMPORTANT] Credentials never cross networks. Session tokens, HMAC API keys, and agent registrations are minted per network and are invalid on any other, so a key leaked or misconfigured on testnet cannot sign for real funds. A
Clientis bound to one network for its lifetime — there is deliberately no setter — so switching networks means constructing a new client with that network's own credentials. Never carry a signature, nonce, or agent registration across networks.
Defaults are chosen to fail safe: omitting network gives testnet, and an
unrecognized network identifier is refused rather than assumed to be play money.
Custom networks
To reach a deployment this SDK does not name — a private stage, a preview host, a
local cluster — build a descriptor and pass it as network:
import { Client, customNetwork } from "@nexus-xyz/exchange-ts";
const client = new Client({
network: customNetwork({
label: "dev",
baseUrl: "https://exchange.example.com/api/exchange",
funds: "play", // required — no default
faucet: true, // optional, defaults to false
wsUrl: "wss://stream.example.com", // optional; derived from baseUrl if omitted
signingChainId: 1234, // optional; omit and signing is refused
}),
});This exists so the package can reach any deployment while shipping a hostname for none of them: enumerating private stages in a published artifact would expose them to every external user, permanently and discoverably, and the list would grow with every new environment. You supply the host; nothing here checks it against an allowlist or a denylist, because that would put a hostname back in.
It carries the whole bundle rather than just a URL, because a URL alone is what lets a client report play-funds guardrails while pointed at a real-funds host:
| Field | Required | Notes |
| ---------------- | -------- | ------------------------------------------------------------------------- |
| label | yes | [A-Za-z0-9._-], ≤64 chars, not ./.. — it is a credential-store key |
| baseUrl | yes | absolute http(s), without /api/v1, no userinfo/query/fragment |
| funds | yes | "play" \| "real" \| "unknown" — no default |
| faucet | no | defaults to false; only valid with funds: "play" |
| wsUrl | no | origin only; omitted ⇒ derived from baseUrl's origin |
| signingChainId | no | omitted ⇒ requireSigningChainId() refuses rather than guessing a domain |
Everything optional is absent until declared, never inferred. A rejected
field throws from customNetwork(), before a client exists; a hand-written
object literal is re-validated by the Client constructor rather than trusted,
since untyped callers and JSON.parse bypass the types.
The label is constrained because sibling clients namespace stored credentials
by it (the CLI puts it in a keyring entry or a path), so ../other or one/two
must not be able to address another target's keys.
Custom is client-side only: not a value the server accepts, and not present in
the spec's x-nexus-networks.
Money guardrails read the descriptor
The funding helpers refuse locally — before a request is built, let alone signed — unless the target declares both play funds and a faucet:
| Target | claimFaucet() / claimCredit() |
| ----------------------------------------------- | -------------------------------------- |
| funds: "play" + faucet: true | claims |
| funds: "play", no faucet | refused — nothing to claim from |
| funds: "real" | refused — never claims free collateral |
| funds: "unknown" (including a bare baseUrl) | refused — undeclared is not "safe" |
Unlike Network.Mainnet, a real-funds custom target is reachable. That is not
a contradiction: mainnet is refused because this release cannot build correct URLs
for its durable base (the version sits in the base, …/v1, not the path), which
is a URL-layout problem rather than a funds one — and with a custom target you
supply the URL and own the layout.
What baseUrl is
Deprecated. The
baseUrlclient option is deprecated in favour ofnetwork: customNetwork({ … })— the descriptor declares the target's funds, a bare URL cannot. It still works, unchanged, and is not scheduled for removal in this release. Your editor strikes it through and deprecation-aware lint rules flag it; nothing warns at runtime.
baseUrl names a deployment, not a surface: scheme, host, and whatever
prefix that deployment mounts the API under. On the public host that is
https://exchange.nexus.xyz/api/exchange; on a direct-service host it is a bare
origin. The version prefix is not part of it — the client appends
/api/v1 to every route, so a base carrying it is refused at construction
rather than sending /api/v1/api/v1/orders:
new Client({ baseUrl: "https://your-host/api/exchange" });Every route hangs off this base, including the ones (/auth/login, /keys,
/agents/*, /ws/token, /ws) that have no /api/v1 variant yet — those drop
the version prefix but stay under the base. client.wsUrl is derived from the
base's origin, so the stream can never end up on a different host than the
REST calls.
The Python SDK carries a second base for those v1-less routes
(direct_base_url, at the host root). One field is enough here because on the
public deployment both surfaces are co-mounted under the gateway —
POST /api/exchange/ws/token answers 401 while host-root POST /ws/token
301s to the marketing site. That is an assumption about this deployment
rather than a property of the protocol: if one ever serves those routes beside
the gateway instead of under it, this SDK would need the second field too.
The signed path is composed independently: it is the logical path
(/api/v1/orders, or /ws/token), never the base's own prefix. That
separation is what lets one base serve a gateway deployment correctly — the
gateway strips /api/exchange before the indexer verifies, so folding it into
the signature would break every authenticated call.
baseUrl is sugar for customNetwork() with nothing declared, so there is
one mechanism for pointing at a host and every guardrail reads the same fields.
It builds a target whose funds are "unknown", with no faucet and no signing
domain, and it replaces network rather than modifying it:
const client = new Client({
network: Network.Testnet,
baseUrl: "https://your-host/api/exchange",
});
client.funds; // "unknown" — not testnet's "play"
client.isRealFunds; // true — undeclared fails closed
client.claimFaucet(); // rejects locally, before anything is signedThat is the point: previously the override kept the selected network's safety
metadata, so a testnet-selected client reported play-funds guardrails while
pointed at whatever host you gave it. Declare the target with customNetwork()
to get those guardrails back honestly.
Porting a base URL between the Nexus SDKs is now direct, because they agree on what the field means — the prefix is appended by the SDK in every one of them:
| SDK | Field | Value for testnet |
| ------ | ----------------------------- | ----------------------------------------- |
| ts | baseUrl | https://exchange.nexus.xyz/api/exchange |
| py | base_url | https://exchange.nexus.xyz/api/exchange |
| rs | Network::Testnet.base_url() | https://exchange.nexus.xyz/api/exchange |
This is a breaking change from 0.2.x, where baseUrl carried /api/v1 and
was expected to sit at the host root. That layout could not reach the public
deployment: https://exchange.nexus.xyz/api/v1/* 404s to the frontend, and the
gateway base that does answer signed the un-stripped /api/exchange/api/v1/….
Splitting the base from the path fixes both. If you passed an explicit
baseUrl, drop the /api/v1 suffix; if you relied on the default, nothing to
do.
Mainnet is not reachable yet
Network.Mainnet exists so you can write network-generic code today, but
selecting it throws. Two independent reasons, and both would fail only against
real funds — the one environment that cannot be rehearsed:
- DNS/TLS is still pending, so
api.nexus.xyzdoes not resolve. - The path composition differs. The durable per-network hosts carry the
version in the base (
/v1) and pair it with the spec's root paths (/v1+/orders), while this client puts the version in the path and signs it. Pointing it at…/v1would send/v1/api/v1/orderswhile signing/api/v1/orders— a 404 whose signature is over a path the server never sees.
Pass a customNetwork() descriptor to target a host deliberately, declaring what
it moves; a bare baseUrl reaches it too, but with funds: "unknown", so the
money guardrails refuse. Either way, the credentials you pass must belong to the
target — pointing at another network's host is not a way to reuse keys across the
funds boundary.
Never derive a host by interpolating the network name: mainnet is deliberately
off-pattern (api.nexus.xyz, not api.mainnet.nexus.xyz), so
api.{network}.nexus.xyz resolves everywhere testable and breaks only on real
money.
Signing domain
networkConfig(n).signingDomain (type NetworkSigningDomain) is the EIP-712
domain this SDK publishes statically, with chainId: null — meaning this SDK
does not publish the value, not that it is zero. The SigningDomain model is
the different, server-reported shape: the wire form of /metadata's
signing_domain (snake_case chain_id, all fields optional), authoritative at
runtime — see Metadata and NetworkTarget for the rest of that payload. The
domain is per-network and server-authoritative: read
signing_domain.chain_id from GET /metadata for the network you are connected
to and pass it to EthSigner.registerAgent({ chainId }). A custom target can
declare one up front (customNetwork({ signingChainId }));
client.requireSigningChainId() then returns it, and throws when none is
known, which is the refusal spelled out as one call:
signer.registerAgent({ agent, chainId: client.requireSigningChainId(), ... });Only the chain id is ever caller-supplied — the EIP-712 name/version are
contract-level constants, identical on every deployment. If you cannot obtain a
chain id, refuse to sign rather than defaulting — a wrong domain either fails
verification or produces a signature valid on a different network. 0 and
out-of-range values are rejected for exactly that reason. Do not assume a Nexus
L1 chain id: mainnet runs against Ethereum Mainnet via the USDX bridge.
Pagination
List endpoints have auto-paging *Paginated variants (fetchTradesPaginated,
getFillsPaginated, getOrderHistoryPaginated, getEquityHistoryPaginated,
getClosedPositionsPaginated) that return a Paginator, mirroring the Rust
SDK. Collect everything with .all(), walk pages with .nextPage(), or stream
item-by-item with for await. Set the per-page size with .pageSize(n) and cap
total pages with .maxPages(n); resume from a saved cursor with
.startingAfter(cursor).
// Stream every account fill without holding them all in memory.
for await (const fill of client.getFillsPaginated().pageSize(100)) {
console.log(fill.id, fill.price, fill.size);
}
// Or collect a bounded slice.
const recent = await client
.fetchTradesPaginated("BTC-USDX-PERP")
.pageSize(100)
.maxPages(5)
.all();The paginator drives the cursor for you: it sends the opaque cursor query
parameter and reads the next one off the X-Next-Cursor response header, so
.all() really does walk every page. No request is issued until the first page
is pulled.
Termination:
- No
X-Next-Cursor⇒ the last page. Not an error, and not a reason to retry. - An empty page that still carries a cursor is not the end — a sparse window keeps paging.
- A server that hands back the same cursor it was given cannot advance, so the
paginator returns that page and stops rather than re-issuing one request
forever. (The Python SDK raises
PaginationErrorhere instead; in TS the last page's non-nullnextCursormakes the stall visible without an error type.) - Nothing else bounds how far back a walk goes; pass
.maxPages(n)when that matters.
.pageSize(n) is checked against that endpoint's spec maximum before the
request is built, so an out-of-schema page size fails locally (as a terminal
InvalidRequestError) instead of being signed and sent. The maxima are per
endpoint and not interchangeable:
| endpoint | method | limit max |
| ----------------------------- | ----------------------------- | --------------------------------------------------- |
| GET /markets/{id}/trades | fetchTradesPaginated | TRADES_LIMIT_MAX = 1000 |
| GET /fills | getFillsPaginated | FILLS_LIMIT_MAX = 1000 |
| GET /orders/history | getOrderHistoryPaginated | ORDER_HISTORY_LIMIT_MAX = 500 |
| GET /positions/closed | getClosedPositionsPaginated | CLOSED_POSITIONS_LIMIT_MAX = 200 |
| GET /account/equity-history | getEquityHistoryPaginated | EQUITY_HISTORY_LIMIT_MAX = 720 (also the default) |
The 366 that appears in the spec belongs to /account/portfolio-history, which
has no cursor parameter and is not paginated — applying it here would reject
valid requests, and on /account/equity-history it sits below that endpoint's own
default of 720.
The flat getters (fetchTrades, getFills, getOrderHistory,
getClosedPositions, getEquityHistory) return the first page only and take
the same limit bound.
Wallet sign-in, sessions & API-key management
HMAC API keys are minted from a wallet. EthSigner wraps an EVM private key and
produces the wallet-authorized payloads locally — the key never leaves the
process. Signing matches the Rust SDK byte-for-byte (EIP-191 personal_sign for
login; EIP-712 RegisterAgent for agents) and is cross-checked against its
known-answer vectors.
import { Client, EthSigner, Network } from "@nexus-xyz/exchange-ts";
const client = new Client({ network: Network.Testnet });
const wallet = EthSigner.fromHex(process.env.WALLET_PRIVATE_KEY!);
// Exchange an EIP-191 signature for a 24h session token (stored on the client).
await client.signIn(wallet);
// Manage HMAC API keys with that session token.
const created = await client.createApiKey(); // { key_id, secret } — secret shown ONCE
const keys = await client.listApiKeys(); // [{ key_id, tier }]
await client.deleteApiKey(created.key_id);Session tokens authenticate only the /keys endpoints and expire after 24h;
call signIn again to renew, or setSessionToken(...) to supply/clear one.
Agent keys let a derived keypair sign trading requests without exposing the main wallet. Registration is authorized by the wallet's EIP-712 signature (no session needed); listing and revoking use HMAC API-key credentials:
const agent = EthSigner.fromHex(process.env.AGENT_PRIVATE_KEY!);
await client.registerAgent(
wallet.registerAgent({
agent: agent.address,
chainId: 393, // exchange testnet chain id
expiresAtMs: Date.now() + 30 * 24 * 3600_000,
nonce: Date.now(),
label: "my-bot",
}),
);
// With apiKey/apiSecret configured:
const agents = await client.listAgents();
await client.revokeAgent(agent.address);Bridge (deposits)
getBridgeAssets, createBridgeDepositAddress, listBridgeDepositAddresses,
getBridgeDeposits, and getBridgeDeposit wrap the /bridge Phase A surface
(USDC/USDX). Get-or-create a per-chain deposit address (idempotent per account +
chain), send funds to it, then poll a deposit until its status is credited:
const { chains } = await client.getBridgeAssets();
const addr = await client.createBridgeDepositAddress(chains[0].chain);
console.log(`send USDC/USDX to ${addr.address} on ${addr.chain}`);
const [deposit] = await client.getBridgeDeposits({
limit: 1,
chain: addr.chain,
});
// deposit?.status: "detected" | "confirming" | "credited" | "failed"WebSocket streaming
createWsClient multiplexes any number of channel subscriptions onto a single
socket, tracks per-channel sequence numbers, and reconnects with replay-from-
lastSeq on drop. Each subscription is an AsyncIterable<WsEvent>.
import { createWsClient } from "@nexus-xyz/exchange-ts";
// Public market data — no auth.
const client = createWsClient({ url: "wss://stream.exchange.nexus.xyz" });
const book = client.subscribe("book", { market: "BTC-PERP" });
for await (const evt of book.events) {
if (evt.outOfSync) {
// Stream lost continuity — refetch a REST snapshot, then keep going.
continue;
}
console.log(evt.seq, evt.data);
}Public channels (book, trades, candles) need no authentication.
Account-scoped channels (orders, fills, positions, balances,
liquidations) require a short-lived token: pass a tokenProvider that mints
one. It is called on every (re)connect, so it always supplies a fresh token.
liquidations (spec v0.7.3) delivers pre-liquidation warnings and the terminal
portfolio-liquidation notice; cast evt.data to LiquidationEvent, which is
externally tagged — exactly one of LiquidationAlert / PortfolioLiquidation is
present, and unrecognized keys should be ignored. Alerts are edge-triggered:
one per worsening severity transition, never on recovery and never repeated while
a severity holds, so treat each event as the whole notification rather than a
level to poll. The spec's venue-wide engine channel is deliberately not
accepted yet — it acks subscriptions but publishes no frames and is documented as
reserved.
const client = createWsClient({
url: "wss://stream.exchange.nexus.xyz",
tokenProvider: async () => myMintWsToken(), // your auth, e.g. an agent-signed mint
});
const orders = client.subscribe("orders");The token rides the connection URL as ?token=…, so the client refuses to mint
one over an insecure ws:// connection to a non-loopback host — use wss://.
On Node < 22 (no global WebSocket), pass WebSocketImpl (e.g. the ws
package). Call client.close() to tear everything down.
Typed models
import { ... } from "@nexus-xyz/exchange-ts" gives you typed
request/response models for every Exchange API resource (orders, fills,
positions, markets, tickers, …). They mirror the component schemas in the
vendored spec (spec/openapi.json) one-for-one.
Money and other exact quantities are typed as Decimal (a string) and are
serialized losslessly — parse them with a decimal library, never a JS number,
or you will lose precision. CCXT-shaped market-data fields (ticker, trade,
order book) are JSON numbers, matching the wire.
Request conventions
Every request carries two advisory identity headers, matching the documented Nexus Exchange API request conventions:
| Header | Default | Purpose |
| --------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| X-Nexus-Api-Version | the pinned spec tag (API_VERSION, from .api-version) | attribute traffic to the spec version the client was built against |
| User-Agent | nexus-exchange-ts/<version> (DEFAULT_USER_AGENT) | per-client usage metering |
Both are advisory — the server never rejects or routes on them, and they sit
outside the HMAC signature (so they are unauthenticated and must never be used
for access control). Override either per client via userAgent / apiVersion
(e.g. when embedding the SDK in a CLI or MCP server), or pass an empty string to
omit it.
Browser caveat:
User-Agentis a forbidden header name forfetch, so browsers silently drop it — it is applied only on runtimes that allow it (e.g. Node).X-Nexus-Api-Versionis sent everywhere.
API version
This SDK targets a released version of the Exchange API spec, pinned in
.api-version and vendored at spec/openapi.json.
The spec lives in
nexus-xyz/nexus-exchange-api.
A drift check (pnpm run check:drift, run in CI on every pull request) keeps
the pin, the vendored spec, the targeted schema list
(spec/schemas.txt), the operations manifest
(endpoints.txt), and the hand-written client and models in
lockstep. If the upstream spec adds, renames, or removes a schema, an enum
member, or an operation, the check fails until the SDK and the pin are updated to
match. It also verifies the vendored spec still byte-matches the upstream
spec at the pinned tag, so the vendored copy can't be hand-edited into agreeing
with itself.
The invariants that matter most run both ways:
- enum members — every
enumin the spec must have exactly the same members in the matchingsrc/models.tsunion, so a new upstream value (or a stray one the spec dropped) fails the gate. Values the SDK deliberately ships ahead of the spec are recorded inspec/enum-allowlist.txt. - operations — every line in
endpoints.txtmust exist in the spec, and the set must equal the REST operationssrc/client.tsactually implements. So the manifest can neither claim coverage the code lacks nor miss a wrapper someone added, and a mis-prefixed path fails rather than quietly overstating coverage. Spec operations the SDK deliberately does not target are recorded inspec/uncovered-ops.txt, so new upstream surface can't land unnoticed.
Every allowlist entry is itself checked for staleness — it fails once the spec
catches up or the code moves on, so no list can accumulate dead grants. The
checker is itself tested: test/models.test.ts defeats each invariant in a
throwaway copy of the drift inputs and asserts the gate goes red, since a green
run is only worth what proves it can fail.
Spec releases are picked up automatically: spec-autobump polls for a newer
release (and is poked by the spec repo on publish), classifies the delta with
oasdiff, re-vendors the spec, and opens a
labelled PR — spec-autobump for a non-breaking delta, breaking ·
needs-SDK-update for one that needs SDK changes. To re-vendor by hand, run
pnpm run bump:spec vX.Y.Z; never edit spec/openapi.json
directly.
Releasing
Releases are automated. release-please
watches main and, from the Conventional Commit history, maintains a "release
PR" that bumps the version (in package.json, .release-please-manifest.json,
and the SDK_VERSION constant) and updates the changelog. Merging that PR
is the release: release-please tags the commit and cuts a GitHub release, and
the Release workflow then re-runs the full
build/lint/test gate and pnpm publishes to npm.
The published tarball carries npm provenance
(--provenance), attesting it was built from this repo at that commit. The
artifact published is smoke-tested on every PR and again before publish via
pnpm run verify:pack, which installs the packed tarball into a throwaway
project and imports it.
One-time setup: add an NPM_TOKEN repository secret (a granular automation
token scoped to publish @nexus-xyz/exchange-ts). The npm-publish
environment
can hold the secret and an optional manual-approval gate. As an even stronger
alternative, npm trusted publishing
(OIDC) removes the long-lived token entirely.
License
Dual-licensed under MIT or Apache-2.0, at your option — same as the other Nexus Exchange SDKs.
