Repository Public
1// dYdX v4 (dYdX Chain) — READ side of the venue adapter, on the public Indexer API (see docs/v4/venues/dydx.md).2// Nothing here signs or sends anything: only GET requests and websocket subscriptions.3import { D, type DecStr } from '../../decimal.ts';4import type { AccountState, Book, Candle, FundingPayment, Instrument, PublicTrade, Ticker } from '../../models.ts';5import type { Capabilities, StreamStatus, StreamSub, VenueAdapter } from '../../venue.ts';6import { ds, venueError, venueHttp, type HttpAnswer } from '../http.ts';7import { DYDX, fillOf, instrumentId, instrumentOf, orderOf, parseAccount, positionOf, tickerOf, tradeOf, type DydxFill, type DydxOrder, type DydxPosition } from './map.ts';8import { DydxStream, type DydxStreamEvent, type WebSocketCtor } from './stream.ts';9import * as W from './wire.ts';10import { z } from 'zod';1112/** Pinned public endpoints — https://docs.dydx.xyz/interaction/endpoints */13export const DYDX_ENDPOINTS = {14 mainnet: { rest: 'https://indexer.dydx.trade/v4', ws: 'wss://indexer.dydx.trade/v4/ws', chainId: 'dydx-mainnet-1' },15 testnet: { rest: 'https://indexer.v4testnet.dydx.exchange/v4', ws: 'wss://indexer.v4testnet.dydx.exchange/v4/ws', chainId: 'dydx-testnet-4' },16} as const;17const RESOLUTION = { '1m': '1MIN', '5m': '5MINS', '15m': '15MINS', '1h': '1HOUR', '4h': '4HOURS', '1d': '1DAY' } as const;18/** the indexer serves at most 1000 rows per page on these lists */19const PAGE = 1000;20const MAX_PAGES = 25;2122export const DYDX_CAPABILITIES: Capabilities = {23 marketData: true, account: true, orders: false, orderTypes: [], tif: [], modify: false, leverage: false,24 marginModes: ['cross', 'isolated'], subaccounts: true, testnet: true, stream: true,25 notes: [26 'Read-only delivery: no order, cancel, deposit or withdrawal is built or sent.',27 'Trading would require a Cosmos secp256k1 key (dydx1… address), protobuf SIGN_DIRECT transactions (MsgPlaceOrder / MsgCancelOrder / MsgBatchCancel) broadcast to a validator, good-til-block handling for short-term orders and sequence handling for stateful ones; a trading-only key is possible through permissioned keys (authenticators) — see docs/v4/venues/dydx.md.',28 'The dYdX terms exclude the United States, Canada and the United Kingdom among others; the mainnet indexer answers account endpoints with HTTP 403 GEOBLOCKED from a blocked location (reported as VenueError "rejected"). Market data is not blocked.',29 'No separate mark price: mark = index = the protocol oracle price. Ticker.mid is null (read the book). Ticker.funding is the estimated next hourly rate.',30 'Account identifier: "dydx1…/<subaccountNumber>" (default 0). Subaccounts ≥ 128 are isolated-margin children of subaccount (n mod 128); they are read one at a time.',31 'Position.leverage, Position.margin are derived from venue figures (notional at oracle price ÷ subaccount equity; notional × initial margin fraction). Position.liquidationPrice, AccountState.maintenanceMargin and withdrawable are null: the indexer does not publish them.',32 'Order.createdAt is 0: the indexer gives the creation block height only. Indexer states BEST_EFFORT_OPENED / BEST_EFFORT_CANCELED are reported as "pending" (not final).',33 'FundingPayment.amount keeps the venue sign: positive = received by the account.',34 ],35};3637export interface DydxAdapterOptions { network?: 'mainnet' | 'testnet'; fetch?: typeof fetch; now?: () => number; restUrl?: string; wsUrl?: string; WebSocket?: WebSocketCtor }38export interface DydxAdapter extends VenueAdapter {39 positions(account: string): Promise<DydxPosition[]>;40 openOrders(account: string): Promise<DydxOrder[]>;41 orderHistory(account: string, o?: { limit?: number }): Promise<DydxOrder[]>;42 fills(account: string, o?: { start?: number; limit?: number }): Promise<DydxFill[]>;43 stream(sub: StreamSub, on: (ev: unknown) => void, onStatus: (s: StreamStatus) => void): () => void;44 /** every subaccount number that exists for an address (parents and isolated children) */45 subaccounts(address: string): Promise<number[]>;46 /** latest block height and time the indexer has processed — how far behind the chain the answers may be */47 height(): Promise<{ height: string; ts: number }>;48}4950export function createDydxAdapter(o: DydxAdapterOptions = {}): DydxAdapter {51 const network = o.network ?? 'mainnet', now = o.now ?? Date.now;52 const base = o.restUrl ?? DYDX_ENDPOINTS[network].rest;53 const http = venueHttp(DYDX, o.fetch);54 let streamConn: DydxStream | null = null;5556 // 403 GEOBLOCKED, 404 "not found" and 400 validation errors all come as { errors: [{ msg, code? }] }57 const on4xx = (a: HttpAnswer) => {58 let msg = a.text.slice(0, 200), code: string | undefined;59 try { const p = W.errorAnswer.safeParse(JSON.parse(a.text)); if (p.success) { msg = p.data.errors[0]!.msg; code = p.data.errors[0]!.code; } } catch { /* keep the raw text */ }60 if (a.status === 403 && code === 'GEOBLOCKED') return venueError(DYDX, 'rejected', `GEOBLOCKED: the indexer refuses this request from the current location (${msg.slice(0, 120)})`);61 return venueError(DYDX, 'rejected', `HTTP ${a.status}: ${msg.slice(0, 200)}`);62 };63 const get = <T>(path: string, schema: z.ZodType<T>) => http.get(`${base}${path}`, schema, { on4xx });64 const q = (p: Record<string, string | number | undefined>) => {65 const s = Object.entries(p).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&');66 return s ? `?${s}` : '';67 };68 const sym = (symbol: string) => {69 if (!/^[A-Za-z0-9,._-]{1,80}$/.test(symbol)) throw venueError(DYDX, 'rejected', 'invalid market ticker');70 return encodeURIComponent(symbol);71 };72 const markets = async () => (await get('/perpetualMarkets', W.perpetualMarkets)).markets;73 const whose = (account: string) => { const a = parseAccount(account); return { ...a, qs: { address: a.address, subaccountNumber: a.subaccountNumber } }; };7475 return {76 id: DYDX, network, capabilities: DYDX_CAPABILITIES,7778 async instruments(): Promise<Instrument[]> { return Object.values(await markets()).map(instrumentOf); },79 async tickers(): Promise<Ticker[]> { const ts = now(); return Object.values(await markets()).map((m) => tickerOf(m, ts)); },8081 async book(symbol: string, depth = 50): Promise<Book> {82 const b = await get(`/orderbooks/perpetualMarket/${sym(symbol)}`, W.orderbook);83 const side = (l: { price: string; size: string }[], dir: 1 | -1) =>84 l.map((x): [DecStr, DecStr] => [x.price, x.size]).sort((a, c) => dir * D.cmp(D.from(a[0]), D.from(c[0]))).slice(0, Math.max(1, depth));85 return { instrument: instrumentId(symbol), bids: side(b.bids, -1), asks: side(b.asks, 1), ts: now() };86 },8788 async trades(symbol: string, limit = 100): Promise<PublicTrade[]> {89 const t = await get(`/trades/perpetualMarket/${sym(symbol)}${q({ limit: Math.min(Math.max(1, limit), PAGE) })}`, W.trades);90 return t.trades.map((x) => tradeOf(symbol, x));91 },9293 async candles(symbol, interval, c): Promise<Candle[]> {94 const r = await get(`/candles/perpetualMarkets/${sym(symbol)}${q({ resolution: RESOLUTION[interval], limit: Math.min(Math.max(1, c.limit ?? 500), PAGE),95 fromISO: c.start === undefined ? undefined : new Date(c.start).toISOString(), toISO: c.end === undefined ? undefined : new Date(c.end).toISOString() })}`, W.candles);96 // volume is in base units (`baseTokenVolume`); oldest first97 return r.candles.map((x): Candle => ({ ts: Date.parse(x.startedAt), open: x.open, high: x.high, low: x.low, close: x.close, volume: x.baseTokenVolume })).sort((a, b) => a.ts - b.ts);98 },99100 async fundingHistory(symbol, f) {101 const out: { ts: number; rate: DecStr; premium: DecStr | null }[] = [];102 let before: number | undefined = f.end;103 for (let page = 0; page < MAX_PAGES; page++) {104 const r = await get(`/historicalFunding/${sym(symbol)}${q({ limit: PAGE, effectiveBeforeOrAt: before === undefined ? undefined : new Date(before).toISOString() })}`, W.historicalFunding);105 const rows = r.historicalFunding.map((x) => ({ ts: Date.parse(x.effectiveAt), rate: x.rate, premium: null })); // hourly rate actually applied; the premium is not published106 for (const row of rows) if (row.ts >= f.start && (f.end === undefined || row.ts <= f.end)) out.push(row);107 const oldest = rows.reduce((m, x) => Math.min(m, x.ts), Infinity);108 if (rows.length < PAGE || oldest <= f.start) break;109 before = oldest - 1;110 }111 const seen = new Set<number>();112 return out.filter((x) => (seen.has(x.ts) ? false : (seen.add(x.ts), true))).sort((a, b) => a.ts - b.ts);113 },114115 async account(account: string): Promise<AccountState> {116 const a = whose(account);117 const s = (await get(`/addresses/${a.address}/subaccountNumber/${a.subaccountNumber}`, W.subaccountAnswer)).subaccount;118 const usdc = s.assetPositions.USDC;119 const collateral = usdc ? D.from(usdc.size) : D.from('0');120 return {121 venue: DYDX, account: a.id, equity: s.equity, freeCollateral: s.freeCollateral,122 collateral: ds(usdc?.side === 'SHORT' ? D.neg(D.abs(collateral)) : collateral), // USDC balance of the subaccount (negative when borrowed against positions)123 marginUsed: ds(D.sub(D.from(s.equity), D.from(s.freeCollateral))),124 maintenanceMargin: null, withdrawable: null, ts: now(), source: 'venue',125 };126 },127128 async positions(account: string): Promise<DydxPosition[]> {129 const a = whose(account);130 const [s, m] = await Promise.all([get(`/addresses/${a.address}/subaccountNumber/${a.subaccountNumber}`, W.subaccountAnswer), markets()]);131 return Object.values(s.subaccount.openPerpetualPositions).filter((p) => p.status === 'OPEN').map((p) => {132 const market = m[p.market];133 if (!market) throw venueError(DYDX, 'bad_answer', `position in unknown market ${p.market}`);134 return positionOf(p, market, s.subaccount.equity);135 });136 },137138 async openOrders(account: string): Promise<DydxOrder[]> {139 const a = whose(account);140 return (await get(`/orders${q({ ...a.qs, status: 'OPEN,UNTRIGGERED,BEST_EFFORT_OPENED', limit: PAGE })}`, W.orders)).map(orderOf);141 },142 async orderHistory(account: string, h?: { limit?: number }): Promise<DydxOrder[]> {143 const a = whose(account);144 return (await get(`/orders${q({ ...a.qs, limit: Math.min(Math.max(1, h?.limit ?? 100), PAGE), returnLatestOrders: 'true' })}`, W.orders)).map(orderOf);145 },146 async fills(account: string, f?: { start?: number; limit?: number }): Promise<DydxFill[]> {147 const a = whose(account);148 const r = await get(`/fills${q({ ...a.qs, limit: Math.min(Math.max(1, f?.limit ?? 100), PAGE) })}`, W.fills);149 return r.fills.map(fillOf).filter((x) => f?.start === undefined || x.ts >= f.start);150 },151 async funding(account: string, f?: { start?: number }): Promise<FundingPayment[]> {152 const a = whose(account);153 const r = await get(`/fundingPayments${q({ ...a.qs, limit: PAGE })}`, W.fundingPayments);154 return r.fundingPayments155 .map((x): FundingPayment => ({ instrument: instrumentId(x.ticker), amount: x.payment, rate: x.rate, size: x.size, ts: Date.parse(x.createdAt) }))156 .filter((x) => f?.start === undefined || x.ts >= f.start);157 },158159 async subaccounts(address: string): Promise<number[]> {160 const a = parseAccount(address);161 return (await get(`/addresses/${a.address}`, W.addressAnswer)).subaccounts.map((s) => s.subaccountNumber).sort((x, y) => x - y);162 },163 async height() {164 const h = await get('/height', z.object({ height: z.string().regex(/^\d+$/), time: z.string() }));165 return { height: h.height, ts: Date.parse(h.time) };166 },167168 stream(sub: StreamSub, on: (ev: unknown) => void, onStatus: (s: StreamStatus) => void): () => void {169 streamConn ??= new DydxStream({ url: o.wsUrl ?? DYDX_ENDPOINTS[network].ws, WebSocket: o.WebSocket, now });170 const cb = on as (ev: DydxStreamEvent) => void;171 if (sub.type === 'account') return streamConn.subscribe('v4_subaccounts', parseAccount(sub.account).id, null, cb, onStatus);172 sym(sub.symbol);173 if (sub.type === 'book') return streamConn.subscribe('v4_orderbook', sub.symbol, null, cb, onStatus);174 if (sub.type === 'trades') return streamConn.subscribe('v4_trades', sub.symbol, null, cb, onStatus);175 return streamConn.subscribe('v4_markets', null, sub.symbol, cb, onStatus);176 },177 };178}179