diff --git a/.changeset/sdk-usdh-orders.md b/.changeset/sdk-usdh-orders.md new file mode 100644 index 0000000..b4aeb1d --- /dev/null +++ b/.changeset/sdk-usdh-orders.md @@ -0,0 +1,10 @@ +--- +'@usdh-kit/sdk': minor +--- + +Add USDH-only spot order layer. The kit now exposes `placeOrder`, +`cancelOrder`, `getOpenOrders`, and `getOrderStatus`, all gated to spot pairs +where USDH is base or quote. `placeOrder` accepts a limit price (with +`tif: 'Gtc' | 'Ioc' | 'Alo'`) or, when omitted, runs as a slippage-tolerant +market order via IOC. `swap()` is unchanged. `InfoClient` gains +`frontendOpenOrders` and `orderStatus` reads to back the new methods. diff --git a/README.md b/README.md index 87fd19b..4a4592f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ A few real flows the SDK is shaped for today. Runnable examples are still on the - HyperEVM → HyperCore bridge with credit polling (`bridgeToCore`) - HyperCore balance, route/preflight helpers plus `bridgeAndSwap()` orchestration - Experimental read-only outcome market metadata, books, and mids +- USDH-only spot order helpers for placing, cancelling, and reading USDH-pair orders - Wallet-agnostic `Signer` interface (works with viem, ethers, Privy, Turnkey, raw private key) - Approved Hyperliquid agent wallet flow for browser apps (`approveAgent`, `accountAddress`) - Read-only `InfoClient` (spotMeta, outcomeMeta, spot clearinghouse state, L2 book, allMids) for consumers building custom UIs diff --git a/docs/roadmap.md b/docs/roadmap.md index adaf388..35ef726 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -145,13 +145,16 @@ kit.placeOutcomeOrder({ Build only the trading primitives needed for USDH markets: ```ts -kit.placeOrder(...) -kit.cancelOrder(...) -kit.getOpenOrders(...) -kit.getOrderStatus(...) +kit.placeOrder({ pair, side, size, price?, tif?, slippageBps? }) +kit.cancelOrder({ pair, oid }) +kit.getOpenOrders({ pair? }) +kit.getOrderStatus({ pair, oid }) ``` This should be a focused USDH-market order layer, not a full Hyperliquid SDK. +`pair` accepts the live `listPairs()` name such as `@230` and ergonomic token +aliases such as `USDH/USDC` or `HYPE/USDH`; reads remain filtered to USDH-bearing +spot pairs. ### Scope diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c1ea925..229e700 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -170,6 +170,28 @@ const outcomeMids = await kit.getOutcomeMids() console.log(market.name, yesBook.coin, outcomeMids[market.sides[0].coin]) ``` +## Trade USDH spot pairs + +The order layer is scoped to USDH-bearing spot pairs. `pair` accepts the live +`pair.name` returned by `listPairs()` (usually `@`) or a token alias +such as `USDH/USDC` or `HYPE/USDH`. + +```ts +const order = await kit.placeOrder({ + pair: 'USDH/USDC', + side: 'buy', + size: '10', + price: '1', +}) + +await kit.cancelOrder({ pair: 'USDH/USDC', oid: order.oid }) + +const openOrders = await kit.getOpenOrders({ pair: 'USDH/USDC' }) +const status = await kit.getOrderStatus({ pair: 'USDH/USDC', oid: order.oid }) + +console.log(openOrders.length, status.status) +``` + ## Bridge and swap `bridgeAndSwap()` composes the common retail flow: @@ -205,6 +227,7 @@ Unexpected route, bridge, or swap failures are wrapped in `BridgeAndSwapError`. * `bridgeAndSwap()` high-level orchestration with progress callbacks * USDH spot market discovery and read-only books/mids for USDH pairs * Experimental read-only outcome market metadata, books, and mids +* USDH-only spot order helpers for place, cancel, open orders, and order status * Wallet-agnostic `Signer` interface (works with viem, ethers, Privy, Turnkey, raw private key) * Read-only `InfoClient` (spotMeta, outcomeMeta, spot clearinghouse state, L2 book, allMids) * Typed error hierarchy rooted at `UsdhKitError`, including `BridgeAndSwapError` phase/cause context and `isBridgeAndSwapError()` narrowing diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4b45984..d846104 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,6 +17,16 @@ export { outcomeEncoding, outcomeTokenName, } from './outcomes.js' +export type { + CancelOrderInput, + CancelOrderResult, + GetOpenOrdersInput, + GetOrderStatusInput, + OrderSide, + PlaceOrderInput, + PlaceOrderResult, + Tif, +} from './orders.js' export type { GetOutcomeBookInput, GetOutcomeMarketInput, @@ -30,6 +40,10 @@ export type { InfoClient, InfoClientConfig, NSigFigs } from './transport/info.js export type { L2Book, L2Level, + OpenOrder, + OrderStatusDetail, + OrderStatusResponse, + OrderStatusValue, OutcomeMeta, OutcomeMetaOutcome, OutcomeMetaQuestion, diff --git a/packages/sdk/src/kit.ts b/packages/sdk/src/kit.ts index aafe042..8ddea0a 100644 --- a/packages/sdk/src/kit.ts +++ b/packages/sdk/src/kit.ts @@ -14,6 +14,15 @@ import { NetworkError, NotImplementedError, } from './errors.js' +import { + type CancelOrderInput, + type CancelOrderResult, + type GetOpenOrdersInput, + type GetOrderStatusInput, + type PlaceOrderInput, + type PlaceOrderResult, + createOrders, +} from './orders.js' import { type GetOutcomeBookInput, type GetOutcomeMarketInput, @@ -37,7 +46,7 @@ import { isOrderResponse, } from './transport/exchange.js' import { type InfoClient, type NSigFigs, createInfoClient } from './transport/info.js' -import type { L2Book } from './transport/types.js' +import type { L2Book, OpenOrder, OrderStatusResponse } from './transport/types.js' import type { BridgeInput, BridgeResult } from './types/bridge.js' import type { KitConfig } from './types/config.js' import type { Logger } from './types/logger.js' @@ -97,7 +106,7 @@ export interface UsdhKit { listPairs(opts?: ListUsdhPairsOpts): Promise /** Find one USDH-bearing spot pair by base/quote token names. */ getPair(input: GetUsdhPairInput): Promise - /** Fetch the L2 book for a pair name (e.g. "USDH/USDC"). */ + /** Fetch the L2 book for a live pair name, usually `@`. */ getBook(pair: string, opts?: { nSigFigs?: NSigFigs }): Promise /** Fetch mid prices, optionally filtered to USDH-quote pairs. */ getMids(opts?: GetMidsOpts): Promise> @@ -109,6 +118,14 @@ export interface UsdhKit { getOutcomeBook(input: GetOutcomeBookInput): Promise /** Fetch mid prices keyed by encoded outcome side coin, e.g. `#200`. */ getOutcomeMids(): Promise> + /** Place a USDH-pair spot order. Accepts `listPairs()` names or token aliases like `USDH/USDC`. */ + placeOrder(input: PlaceOrderInput): Promise + /** Cancel a resting USDH-pair order by oid. */ + cancelOrder(input: CancelOrderInput): Promise + /** List the user's USDH-pair resting open orders, optionally filtered to one pair. */ + getOpenOrders(input?: GetOpenOrdersInput): Promise + /** Fetch a single USDH-pair order's status by pair and oid. */ + getOrderStatus(input: GetOrderStatusInput): Promise } /** @@ -142,6 +159,15 @@ export function createUsdhKit(config: KitConfig): UsdhKit { return candidate } + const orders = createOrders({ + info, + exchange, + signer: config.signer, + network: config.network, + accountAddress, + nextNonce, + }) + async function swap(input: SwapInput): Promise { validateSwapInput(input) const slippageBps = input.slippageBps ?? defaultSlippageBps @@ -333,6 +359,22 @@ export function createUsdhKit(config: KitConfig): UsdhKit { return outcomeDiscovery.getOutcomeMids() }, + placeOrder(input) { + return orders.placeOrder(input) + }, + + cancelOrder(input) { + return orders.cancelOrder(input) + }, + + getOpenOrders(input) { + return orders.getOpenOrders(input) + }, + + getOrderStatus(input) { + return orders.getOrderStatus(input) + }, + async getQuote(input: QuoteInput): Promise { validateQuoteInput(input) if (input.from === 'USDT') { diff --git a/packages/sdk/src/orders.ts b/packages/sdk/src/orders.ts new file mode 100644 index 0000000..69555fe --- /dev/null +++ b/packages/sdk/src/orders.ts @@ -0,0 +1,439 @@ +import { type UsdhPair, findUsdhSpotPair, listUsdhSpotPairs } from './discovery.js' +import { InvalidInputError, NetworkError } from './errors.js' +import { formatDecimal, formatSpotPrice, midPrice18, parseDecimal } from './pricing.js' +import { signL1Action } from './signing.js' +import { + type ExchangeClient, + type ExchangeResponse, + isOrderResponse, +} from './transport/exchange.js' +import type { InfoClient } from './transport/info.js' +import type { OpenOrder, OrderStatusResponse, SpotPair, SpotToken } from './transport/types.js' +import type { Address } from './types/hex.js' +import type { Network } from './types/network.js' +import type { Signer } from './types/signer.js' + +const SPOT_ASSET_OFFSET = 10_000 +const PRICE_DECIMALS = 18 +const ORDER_EXPIRES_AFTER_MS = 30_000n +const DEFAULT_MARKET_SLIPPAGE_BPS = 50 +const USDH_TOKEN_NAME = 'USDH' + +export type OrderSide = 'buy' | 'sell' +export type Tif = 'Gtc' | 'Ioc' | 'Alo' + +export interface PlaceOrderInput { + /** USDH-bearing spot pair. Accepts `listPairs()` names like `@230` or aliases like `USDH/USDC`. */ + pair: string + side: OrderSide + /** Size in base-token units, decimal string (e.g. "1.5"). */ + size: string + /** Limit price in quote-per-base, decimal string. Omit for a market order. */ + price?: string + /** Time-in-force. Defaults to 'Gtc' for limit; ignored for market. */ + tif?: Tif + /** Reduce-only flag. Defaults to false. */ + reduceOnly?: boolean + /** Slippage tolerance for market orders. Defaults to 50 bps. */ + slippageBps?: number +} + +export interface PlaceOrderResult { + oid: number + /** "filled" if the order completed at submission, "resting" if it sits on the book. */ + status: 'filled' | 'resting' + /** Filled size in base units (decimal string). Empty for resting. */ + filledSize: string + /** Average fill price (decimal string). Empty for resting. */ + avgPrice: string +} + +export interface CancelOrderInput { + /** USDH-bearing spot pair. Accepts `listPairs()` names like `@230` or aliases like `USDH/USDC`. */ + pair: string + /** Order id to cancel. */ + oid: number +} + +export interface CancelOrderResult { + oid: number +} + +export interface GetOpenOrdersInput { + /** Optional USDH-bearing spot pair filter. Accepts the same formats as `placeOrder`. */ + pair?: string +} + +export interface GetOrderStatusInput { + /** USDH-bearing spot pair the order is expected to belong to. */ + pair: string + /** Order id to inspect. */ + oid: number +} + +export interface OrdersDeps { + info: InfoClient + exchange: ExchangeClient + signer: Signer + network: Network + /** Account whose orders are read. Defaults to `signer.address`. */ + accountAddress: Address + nextNonce(): bigint +} + +interface PairContext { + pair: UsdhPair + baseSzDecimals: number + assetIndex: number +} + +/** + * Build a Track 3 USDH-only order layer on top of the existing transport. + * Lookups go through `findUsdhSpotPair` so callers cannot place orders on + * pairs where USDH is neither base nor quote. + */ +export function createOrders(deps: OrdersDeps): { + placeOrder(input: PlaceOrderInput): Promise + cancelOrder(input: CancelOrderInput): Promise + getOpenOrders(input?: GetOpenOrdersInput): Promise + getOrderStatus(input: GetOrderStatusInput): Promise +} { + return { + async placeOrder(input) { + const ctx = await resolvePairContext(deps.info, input.pair) + const isMarket = input.price === undefined + validatePlaceOrder(input, isMarket) + + const sizeStr = normalizeSize(input.size, ctx.baseSzDecimals) + const priceStr = isMarket + ? await marketLimitPrice(deps.info, ctx, input.side, input.slippageBps) + : formatSpotPrice(parseDecimal(input.price as string, PRICE_DECIMALS), ctx.baseSzDecimals) + + const tif: Tif = isMarket ? 'Ioc' : (input.tif ?? 'Gtc') + const action = { + type: 'order', + orders: [ + { + a: ctx.assetIndex, + b: input.side === 'buy', + p: priceStr, + s: sizeStr, + r: input.reduceOnly ?? false, + t: { limit: { tif } }, + }, + ], + grouping: 'na', + } + + const nonce = deps.nextNonce() + const expiresAfter = nonce + ORDER_EXPIRES_AFTER_MS + const signature = await signL1Action({ + signer: deps.signer, + action, + nonce, + network: deps.network, + expiresAfter, + }) + + const response: ExchangeResponse = await deps.exchange.submit({ + action, + signature, + nonce, + expiresAfter, + }) + if (response.status === 'err') { + throw new NetworkError(`exchange error: ${response.response}`) + } + if (!isOrderResponse(response.response)) { + throw new NetworkError('unexpected /exchange response shape for order action') + } + const status = response.response.data.statuses[0] + if (status === undefined) { + throw new NetworkError('exchange returned no order status') + } + if ('error' in status) { + throw new NetworkError(`order error: ${status.error}`) + } + if ('filled' in status) { + return { + oid: status.filled.oid, + status: 'filled', + filledSize: status.filled.totalSz, + avgPrice: status.filled.avgPx, + } + } + return { + oid: status.resting.oid, + status: 'resting', + filledSize: '', + avgPrice: '', + } + }, + + async cancelOrder(input) { + const ctx = await resolvePairContext(deps.info, input.pair) + validateOid(input.oid) + const action = { + type: 'cancel', + cancels: [{ a: ctx.assetIndex, o: input.oid }], + } + const nonce = deps.nextNonce() + const expiresAfter = nonce + ORDER_EXPIRES_AFTER_MS + const signature = await signL1Action({ + signer: deps.signer, + action, + nonce, + network: deps.network, + expiresAfter, + }) + const response: ExchangeResponse = await deps.exchange.submit({ + action, + signature, + nonce, + expiresAfter, + }) + if (response.status === 'err') { + throw new NetworkError(`exchange error: ${response.response}`) + } + const inner = response.response as { type?: unknown; data?: unknown } + if (inner.type !== 'cancel' || !isRecord(inner.data)) { + throw new NetworkError('unexpected /exchange response shape for cancel action') + } + const statuses = (inner.data as { statuses?: unknown }).statuses + if (!Array.isArray(statuses) || statuses[0] === undefined) { + throw new NetworkError('cancel returned no status') + } + const first = statuses[0] + if (first !== 'success') { + const msg = + isRecord(first) && 'error' in first + ? String((first as { error: unknown }).error) + : String(first) + throw new NetworkError(`cancel error: ${msg}`) + } + return { oid: input.oid } + }, + + async getOpenOrders(input) { + const pair = readOptionalPair(input) + const pairFilter = pair === undefined ? null : await resolveOrderCoinNames(deps.info, pair) + const openOrders = await deps.info.frontendOpenOrders(deps.accountAddress) + if (pairFilter !== null) { + return openOrders.filter((order) => pairFilter.has(order.coin)) + } + if (openOrders.length === 0) { + return [] + } + const usdhCoins = await resolveOrderCoinNames(deps.info) + return openOrders.filter((order) => usdhCoins.has(order.coin)) + }, + + async getOrderStatus(input) { + if (!isRecord(input)) { + throw new InvalidInputError('order status input must include pair and oid') + } + const { oid, pair } = input + assertPairInput(pair) + validateOid(oid) + const ctx = await resolvePairContext(deps.info, pair) + const status = await deps.info.orderStatus(deps.accountAddress, oid) + if (status.status === 'unknownOid') { + return status + } + if (!orderMatchesPair(status.order.order, ctx.pair)) { + throw new InvalidInputError(`order ${oid} is not on USDH pair ${ctx.pair.name}`) + } + return status + }, + } +} + +function validatePlaceOrder(input: PlaceOrderInput, isMarket: boolean): void { + if (input.side !== 'buy' && input.side !== 'sell') { + throw new InvalidInputError(`side must be 'buy' or 'sell'`) + } + if (typeof input.size !== 'string' || input.size.length === 0) { + throw new InvalidInputError('size must be a non-empty decimal string') + } + if (input.tif !== undefined && !['Gtc', 'Ioc', 'Alo'].includes(input.tif)) { + throw new InvalidInputError(`tif must be 'Gtc', 'Ioc', or 'Alo'`) + } + if (isMarket && input.tif !== undefined && input.tif !== 'Ioc') { + throw new InvalidInputError('market orders force tif=Ioc; omit tif') + } + if (input.slippageBps !== undefined) { + if ( + !Number.isFinite(input.slippageBps) || + !Number.isInteger(input.slippageBps) || + input.slippageBps < 0 || + input.slippageBps > 10_000 + ) { + throw new InvalidInputError('slippageBps must be an integer in [0, 10000]') + } + if (!isMarket) { + throw new InvalidInputError('slippageBps only applies to market orders') + } + } +} + +function normalizeSize(size: string, szDecimals: number): string { + const parsed = parseDecimal(size, szDecimals) + if (parsed === 0n) { + throw new InvalidInputError('size must be greater than zero') + } + return formatDecimal(parsed, szDecimals) +} + +async function marketLimitPrice( + info: InfoClient, + ctx: PairContext, + side: OrderSide, + slippageBps: number | undefined, +): Promise { + const bps = slippageBps ?? DEFAULT_MARKET_SLIPPAGE_BPS + const book = await info.l2Book(ctx.pair.name) + const mid = midPrice18(book) + const adjusted = + side === 'buy' + ? (mid * (10_000n + BigInt(bps))) / 10_000n + : (mid * (10_000n - BigInt(bps))) / 10_000n + if (adjusted <= 0n) { + throw new InvalidInputError('slippage-adjusted price is non-positive') + } + return formatSpotPrice(adjusted, ctx.baseSzDecimals) +} + +async function resolvePairContext(info: InfoClient, pairInput: string): Promise { + assertPairInput(pairInput) + const meta = await info.spotMeta() + const tokens = new Map(meta.tokens.map((t) => [t.index, t])) + const universePair = meta.universe.find((p) => p.name === pairInput) + if (universePair !== undefined) { + const baseToken = tokens.get(universePair.tokens[0]) + const quoteToken = tokens.get(universePair.tokens[1]) + if (baseToken === undefined || quoteToken === undefined) { + throw new NetworkError(`token metadata missing for pair ${pairInput}`) + } + const pair = usdhPairFromUniversePair(universePair, baseToken, quoteToken) + return { + pair, + baseSzDecimals: baseToken.szDecimals, + assetIndex: SPOT_ASSET_OFFSET + pair.index, + } + } + + const alias = parsePairAlias(pairInput) + if (alias === null) { + throw new InvalidInputError(`pair ${pairInput} not found in spotMeta`) + } + const pair = findInputUsdhSpotPair(meta, alias) + const baseToken = tokens.get(pair.tokens[0]) + if (baseToken === undefined) { + throw new NetworkError(`token metadata missing for pair ${pairInput}`) + } + return { + pair, + baseSzDecimals: baseToken.szDecimals, + assetIndex: SPOT_ASSET_OFFSET + pair.index, + } +} + +async function resolveOrderCoinNames(info: InfoClient, pairInput?: string): Promise> { + if (pairInput !== undefined) { + const ctx = await resolvePairContext(info, pairInput) + return orderCoinNames(ctx.pair) + } + const meta = await info.spotMeta() + const names = new Set() + for (const pair of listUsdhSpotPairs(meta)) { + for (const name of orderCoinNames(pair)) { + names.add(name) + } + } + return names +} + +function readOptionalPair(input: GetOpenOrdersInput | undefined): string | undefined { + if (input === undefined) { + return undefined + } + if (!isRecord(input)) { + throw new InvalidInputError('open orders input must be an object') + } + const { pair } = input + if (pair === undefined) { + return undefined + } + assertPairInput(pair) + return pair +} + +function assertPairInput(pair: unknown): asserts pair is string { + if (typeof pair !== 'string' || pair.length === 0) { + throw new InvalidInputError('pair must be a non-empty string') + } +} + +function parsePairAlias(pair: string): { base: string; quote: string } | null { + const parts = pair.split('/') + if (parts.length !== 2) { + return null + } + const base = parts[0] + const quote = parts[1] + if (base === undefined || quote === undefined || base === '' || quote === '') { + return null + } + return { base, quote } +} + +function findInputUsdhSpotPair( + meta: Parameters[0], + input: Parameters[1], +): UsdhPair { + try { + return findUsdhSpotPair(meta, input) + } catch (error) { + if (error instanceof NetworkError && error.message.startsWith('pair ')) { + throw new InvalidInputError(error.message) + } + throw error + } +} + +function usdhPairFromUniversePair( + pair: SpotPair, + baseToken: SpotToken, + quoteToken: SpotToken, +): UsdhPair { + if (baseToken.name !== USDH_TOKEN_NAME && quoteToken.name !== USDH_TOKEN_NAME) { + throw new InvalidInputError(`pair must have ${USDH_TOKEN_NAME} as base or quote`) + } + return { + kind: 'spot', + name: pair.name, + base: baseToken.name, + quote: quoteToken.name, + usdhRole: baseToken.name === USDH_TOKEN_NAME ? 'base' : 'quote', + index: pair.index, + tokens: pair.tokens, + } +} + +function orderCoinNames(pair: UsdhPair): Set { + return new Set([pair.name, `${pair.base}/${pair.quote}`]) +} + +function orderMatchesPair(order: { coin: string }, pair: UsdhPair): boolean { + return orderCoinNames(pair).has(order.coin) +} + +function validateOid(oid: unknown): asserts oid is number { + if (!Number.isInteger(oid) || typeof oid !== 'number' || oid < 0) { + throw new InvalidInputError('oid must be a non-negative integer') + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/packages/sdk/src/transport/info.ts b/packages/sdk/src/transport/info.ts index e9072de..91bb00f 100644 --- a/packages/sdk/src/transport/info.ts +++ b/packages/sdk/src/transport/info.ts @@ -3,6 +3,8 @@ import type { Address } from '../types/hex.js' import type { Network } from '../types/network.js' import type { L2Book, + OpenOrder, + OrderStatusResponse, OutcomeMeta, OutcomeMetaQuestion, OutcomeSideSpec, @@ -34,6 +36,10 @@ export interface InfoClient { spotClearinghouseState(user: Address): Promise /** Returns mid prices keyed by coin name (perps) or `@` (spot). */ allMids(): Promise> + /** Resting open orders for a user, with extended frontend fields. */ + frontendOpenOrders(user: Address): Promise + /** Status of a single order by oid. */ + orderStatus(user: Address, oid: number): Promise } export function createInfoClient(config: InfoClientConfig): InfoClient { @@ -112,6 +118,15 @@ export function createInfoClient(config: InfoClientConfig): InfoClient { allMids() { return post({ type: 'allMids' }).then(assertAllMids) }, + frontendOpenOrders(user) { + return post({ type: 'frontendOpenOrders', user }).then(assertOpenOrders) + }, + orderStatus(user, oid) { + if (!Number.isInteger(oid) || oid < 0) { + throw new InvalidInputError('oid must be a non-negative integer') + } + return post({ type: 'orderStatus', user, oid }).then(assertOrderStatus) + }, } } @@ -224,6 +239,74 @@ function assertL2Book(data: unknown, coin: string): L2Book { return data as unknown as L2Book } +function assertOpenOrders(data: unknown): OpenOrder[] { + if (!Array.isArray(data)) { + throw new NetworkError('invalid frontendOpenOrders response') + } + for (const item of data) { + if (!isOpenOrder(item)) { + throw new NetworkError('invalid frontendOpenOrders entry') + } + } + return data as OpenOrder[] +} + +function isOpenOrder(value: unknown): boolean { + if (!isRecord(value)) return false + const v = value as { + coin?: unknown + side?: unknown + limitPx?: unknown + sz?: unknown + origSz?: unknown + oid?: unknown + timestamp?: unknown + reduceOnly?: unknown + orderType?: unknown + triggerCondition?: unknown + triggerPx?: unknown + isPositionTpsl?: unknown + isTrigger?: unknown + } + return ( + typeof v.coin === 'string' && + (v.side === 'A' || v.side === 'B') && + typeof v.limitPx === 'string' && + typeof v.sz === 'string' && + typeof v.origSz === 'string' && + typeof v.oid === 'number' && + typeof v.timestamp === 'number' && + typeof v.reduceOnly === 'boolean' && + typeof v.orderType === 'string' && + typeof v.triggerCondition === 'string' && + typeof v.triggerPx === 'string' && + typeof v.isPositionTpsl === 'boolean' && + typeof v.isTrigger === 'boolean' + ) +} + +function assertOrderStatus(data: unknown): OrderStatusResponse { + if (!isRecord(data)) { + throw new NetworkError('invalid orderStatus response') + } + const v = data as { status?: unknown; order?: unknown } + if (v.status === 'unknownOid') { + return { status: 'unknownOid' } + } + if (v.status !== 'order' || !isRecord(v.order)) { + throw new NetworkError('invalid orderStatus response') + } + const detail = v.order as { order?: unknown; status?: unknown; statusTimestamp?: unknown } + if ( + !isRecord(detail.order) || + typeof detail.status !== 'string' || + typeof detail.statusTimestamp !== 'number' + ) { + throw new NetworkError('invalid orderStatus detail') + } + return data as OrderStatusResponse +} + function assertAllMids(data: unknown): Record { if (!isRecord(data)) { throw new NetworkError('invalid allMids response') diff --git a/packages/sdk/src/transport/types.ts b/packages/sdk/src/transport/types.ts index cf23875..6cf5024 100644 --- a/packages/sdk/src/transport/types.ts +++ b/packages/sdk/src/transport/types.ts @@ -83,3 +83,72 @@ export interface OutcomeMeta { outcomes: OutcomeMetaOutcome[] questions?: OutcomeMetaQuestion[] } + +/** A user's resting open order, as returned by `frontendOpenOrders`. */ +export interface OpenOrder { + /** Coin name (e.g. "USDH/USDC") or `@` for non-canonical spot. */ + coin: string + /** "B" = buy/bid, "A" = ask/sell. */ + side: 'A' | 'B' + limitPx: string + sz: string + origSz: string + oid: number + timestamp: number + reduceOnly: boolean + orderType: string + triggerCondition: string + triggerPx: string + isPositionTpsl: boolean + isTrigger: boolean +} + +export type OrderStatusValue = + | 'open' + | 'filled' + | 'canceled' + | 'triggered' + | 'rejected' + | 'marginCanceled' + | 'vaultWithdrawalCanceled' + | 'openInterestCapCanceled' + | 'selfTradeCanceled' + | 'reduceOnlyCanceled' + | 'siblingFilledCanceled' + | 'delistedCanceled' + | 'liquidatedCanceled' + | 'scheduledCancel' + | 'tickRejected' + | 'minTradeNtlRejected' + | 'perpMarginRejected' + | 'badAloPxRejected' + | 'iocCancelRejected' + | 'badTriggerPxRejected' + | 'marketOrderNoLiquidityRejected' + | 'insufficientSpotBalanceRejected' + | 'oracleRejected' + +/** Detailed order info returned by the `orderStatus` endpoint. */ +export interface OrderStatusDetail { + order: { + coin: string + side: 'A' | 'B' + limitPx: string + sz: string + origSz: string + oid: number + timestamp: number + reduceOnly: boolean + orderType: string + triggerCondition: string + triggerPx: string + isPositionTpsl: boolean + isTrigger: boolean + } + status: OrderStatusValue + statusTimestamp: number +} + +export type OrderStatusResponse = + | { status: 'order'; order: OrderStatusDetail } + | { status: 'unknownOid' } diff --git a/packages/sdk/test/bridge.test.ts b/packages/sdk/test/bridge.test.ts index 46d50ca..f9b4a01 100644 --- a/packages/sdk/test/bridge.test.ts +++ b/packages/sdk/test/bridge.test.ts @@ -59,6 +59,8 @@ function stubInfo(states: SpotClearinghouseState[]): InfoClient { return s as SpotClearinghouseState }), allMids: vi.fn(), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), } } @@ -199,6 +201,8 @@ describe('runBridgeToCore', () => { l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), } await expect( runBridgeToCore( @@ -394,6 +398,8 @@ describe('runBridgeToCore', () => { return calls < 4 ? stateWith('0') : stateWith('100') }), allMids: vi.fn(), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), } const wallet = stubWallet(`0x${'a'.repeat(64)}`) let t = 0 diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index 23f5a2c..c15f897 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -147,6 +147,8 @@ describe('createDiscovery', () => { l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), ...overrides, } } diff --git a/packages/sdk/test/info.test.ts b/packages/sdk/test/info.test.ts index be0a769..3ae2c19 100644 --- a/packages/sdk/test/info.test.ts +++ b/packages/sdk/test/info.test.ts @@ -209,6 +209,80 @@ describe('allMids', () => { }) }) +const sampleOpenOrder = { + coin: 'USDH/USDC', + side: 'B', + limitPx: '1.0001', + sz: '10', + origSz: '10', + oid: 91490942, + timestamp: 1681247412573, + reduceOnly: false, + orderType: 'Limit', + triggerCondition: 'N/A', + triggerPx: '0.0', + isPositionTpsl: false, + isTrigger: false, +} + +describe('frontendOpenOrders', () => { + it('posts user and returns the parsed array', async () => { + const fetch = vi.fn(async () => jsonResponse([sampleOpenOrder])) + const client = createInfoClient({ network: 'mainnet', fetch }) + const result = await client.frontendOpenOrders('0x000000000000000000000000000000000000abcd') + const [, init] = fetch.mock.calls[0] ?? [] + expect(JSON.parse(init?.body as string)).toEqual({ + type: 'frontendOpenOrders', + user: '0x000000000000000000000000000000000000abcd', + }) + expect(result).toEqual([sampleOpenOrder]) + }) + + it('rejects an invalid entry shape', async () => { + const fetch = vi.fn(async () => jsonResponse([{ ...sampleOpenOrder, side: 'X' }])) + const client = createInfoClient({ network: 'mainnet', fetch }) + await expect( + client.frontendOpenOrders('0x000000000000000000000000000000000000abcd'), + ).rejects.toThrow(NetworkError) + }) +}) + +describe('orderStatus', () => { + it('returns the parsed status detail', async () => { + const orderDetail = { + order: { ...sampleOpenOrder }, + status: 'open', + statusTimestamp: 1724361546645, + } + const fetch = vi.fn(async () => jsonResponse({ status: 'order', order: orderDetail })) + const client = createInfoClient({ network: 'mainnet', fetch }) + const result = await client.orderStatus('0x000000000000000000000000000000000000abcd', 91490942) + const [, init] = fetch.mock.calls[0] ?? [] + expect(JSON.parse(init?.body as string)).toEqual({ + type: 'orderStatus', + user: '0x000000000000000000000000000000000000abcd', + oid: 91490942, + }) + expect(result).toEqual({ status: 'order', order: orderDetail }) + }) + + it('returns the unknownOid sentinel as-is', async () => { + const fetch = vi.fn(async () => jsonResponse({ status: 'unknownOid' })) + const client = createInfoClient({ network: 'mainnet', fetch }) + const result = await client.orderStatus('0x000000000000000000000000000000000000abcd', 999) + expect(result).toEqual({ status: 'unknownOid' }) + }) + + it('rejects a negative oid synchronously', () => { + const fetch = vi.fn(async () => jsonResponse({ status: 'unknownOid' })) + const client = createInfoClient({ network: 'mainnet', fetch }) + expect(() => client.orderStatus('0x000000000000000000000000000000000000abcd', -1)).toThrow( + InvalidInputError, + ) + expect(fetch).not.toHaveBeenCalled() + }) +}) + describe('error handling', () => { it('wraps non-2xx HTTP status in NetworkError', async () => { const fetch = vi.fn(async () => new Response('rate limited', { status: 429 })) diff --git a/packages/sdk/test/orders.test.ts b/packages/sdk/test/orders.test.ts new file mode 100644 index 0000000..12c9b78 --- /dev/null +++ b/packages/sdk/test/orders.test.ts @@ -0,0 +1,584 @@ +import { describe, expect, it, vi } from 'vitest' + +import { InvalidInputError } from '../src/errors.js' +import { createOrders } from '../src/orders.js' +import type { ExchangeClient } from '../src/transport/exchange.js' +import type { InfoClient } from '../src/transport/info.js' +import type { L2Book, OpenOrder, OrderStatusResponse, SpotMeta } from '../src/transport/types.js' +import type { Address } from '../src/types/hex.js' +import type { Signer } from '../src/types/signer.js' + +const meta: SpotMeta = { + universe: [ + { name: '@230', tokens: [1, 0], index: 230, isCanonical: true }, + { name: '@232', tokens: [2, 1], index: 232, isCanonical: true }, + { name: '@999', tokens: [2, 0], index: 999, isCanonical: false }, + ], + tokens: [ + { + name: 'USDC', + szDecimals: 8, + weiDecimals: 8, + index: 0, + tokenId: '0xaaaa', + isCanonical: true, + }, + { + name: 'USDH', + szDecimals: 8, + weiDecimals: 8, + index: 1, + tokenId: '0xbbbb', + isCanonical: true, + }, + { + name: 'HYPE', + szDecimals: 2, + weiDecimals: 8, + index: 2, + tokenId: '0xcccc', + isCanonical: true, + }, + ], +} + +const sampleBook: L2Book = { + coin: '@232', + time: 1735300000000, + levels: [[{ px: '40.5', sz: '100', n: 1 }], [{ px: '40.6', sz: '100', n: 1 }]], +} + +const ACCOUNT: Address = '0x000000000000000000000000000000000000abcd' + +function stubInfo(overrides: Partial = {}): InfoClient { + return { + spotMeta: vi.fn(async () => meta), + outcomeMeta: vi.fn(), + l2Book: vi.fn(async () => sampleBook), + spotClearinghouseState: vi.fn(), + allMids: vi.fn(), + frontendOpenOrders: vi.fn(async () => []), + orderStatus: vi.fn(async () => ({ status: 'unknownOid' }) as const), + ...overrides, + } +} + +function stubSigner(): Signer { + return { + address: ACCOUNT, + signTypedData: vi.fn(async () => `0x${'11'.repeat(64)}1c` as `0x${string}`), + signMessage: vi.fn(), + } +} + +function nonceFactory() { + let n = 1_700_000_000_000n + return () => { + n += 1n + return n + } +} + +function exchangeOk(response: unknown): ExchangeClient { + return { submit: vi.fn(async () => ({ status: 'ok', response })) } +} + +function exchangeErr(message: string): ExchangeClient { + return { submit: vi.fn(async () => ({ status: 'err', response: message })) } +} + +function openOrder(coin: string, oid: number): OpenOrder { + return { + coin, + side: 'B', + limitPx: '1', + sz: '2', + origSz: '2', + oid, + timestamp: 1735300000000, + reduceOnly: false, + orderType: 'Limit', + triggerCondition: 'N/A', + triggerPx: '0', + isPositionTpsl: false, + isTrigger: false, + } +} + +function orderStatus(coin: string, oid: number): OrderStatusResponse { + return { + status: 'order', + order: { + order: openOrder(coin, oid), + status: 'open', + statusTimestamp: 1735300000001, + }, + } +} + +describe('placeOrder', () => { + it('places a limit order using a live spot pair name', async () => { + const exchange = exchangeOk({ + type: 'order', + data: { statuses: [{ resting: { oid: 7777 } }] }, + }) + const orders = createOrders({ + info: stubInfo(), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + const result = await orders.placeOrder({ + pair: '@230', + side: 'buy', + size: '10', + price: '1', + }) + + expect(result).toEqual({ oid: 7777, status: 'resting', filledSize: '', avgPrice: '' }) + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toMatchObject({ + type: 'order', + grouping: 'na', + orders: [ + { + a: 10_230, + b: true, + p: '1', + s: '10', + r: false, + t: { limit: { tif: 'Gtc' } }, + }, + ], + }) + }) + + it('preserves the exact live pair index when resolving a live pair name', async () => { + const exchange = exchangeOk({ + type: 'order', + data: { statuses: [{ resting: { oid: 7778 } }] }, + }) + const duplicateMeta: SpotMeta = { + ...meta, + universe: [ + { name: '@111', tokens: [1, 0], index: 111, isCanonical: false }, + ...meta.universe, + ], + } + const orders = createOrders({ + info: stubInfo({ spotMeta: vi.fn(async () => duplicateMeta) }), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await orders.placeOrder({ + pair: '@230', + side: 'buy', + size: '10', + price: '1', + }) + + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toMatchObject({ + orders: [{ a: 10_230, b: true }], + }) + }) + + it('places a limit order using a token-pair alias', async () => { + const exchange = exchangeOk({ + type: 'order', + data: { statuses: [{ resting: { oid: 7778 } }] }, + }) + const orders = createOrders({ + info: stubInfo(), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await orders.placeOrder({ + pair: 'USDH/USDC', + side: 'buy', + size: '10', + price: '1', + }) + + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toMatchObject({ + orders: [{ a: 10_230, b: true }], + }) + }) + + it('places a market sell on a USDH-quote pair using slippage-adjusted limit', async () => { + const exchange = exchangeOk({ + type: 'order', + data: { statuses: [{ filled: { totalSz: '5', avgPx: '40.5', oid: 8888 } }] }, + }) + const info = stubInfo() + const orders = createOrders({ + info, + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + const result = await orders.placeOrder({ + pair: 'HYPE/USDH', + side: 'sell', + size: '5', + }) + + expect(result).toEqual({ oid: 8888, status: 'filled', filledSize: '5', avgPrice: '40.5' }) + expect(info.l2Book).toHaveBeenCalledWith('@232') + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toMatchObject({ + orders: [ + { + a: 10_232, + b: false, + s: '5', + t: { limit: { tif: 'Ioc' } }, + }, + ], + }) + // mid = 40.55, sell with 50bps slippage => 40.55 * 0.995 = 40.347... + const submitted = submitArgs?.action as { orders: { p: string }[] } + const px = Number(submitted.orders[0]?.p) + expect(px).toBeGreaterThan(40.3) + expect(px).toBeLessThan(40.4) + }) + + it('rejects a pair where USDH is neither base nor quote', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: 'HYPE/USDC', side: 'buy', size: '1', price: '40' }), + ).rejects.toThrow(/USDH as base or quote/) + }) + + it('rejects an unknown pair alias', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: 'NOPE/USDH', side: 'buy', size: '1', price: '1' }), + ).rejects.toBeInstanceOf(InvalidInputError) + }) + + it('rejects fractional slippageBps with an SDK input error', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: '@232', side: 'sell', size: '1', slippageBps: 12.5 }), + ).rejects.toBeInstanceOf(InvalidInputError) + }) + + it('rejects tif on a market order', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: 'USDH/USDC', side: 'buy', size: '1', tif: 'Gtc' }), + ).rejects.toThrow(/market orders force tif=Ioc/) + }) + + it('rejects slippageBps on a limit order', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ + pair: 'USDH/USDC', + side: 'buy', + size: '1', + price: '1', + slippageBps: 10, + }), + ).rejects.toThrow(/slippageBps only applies to market orders/) + }) + + it('rejects an invalid side', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'order', data: { statuses: [] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + // biome-ignore lint/suspicious/noExplicitAny: deliberately bad input + orders.placeOrder({ pair: 'USDH/USDC', side: 'long' as any, size: '1', price: '1' }), + ).rejects.toBeInstanceOf(InvalidInputError) + }) + + it('surfaces an order-level error from the exchange', async () => { + const exchange = exchangeOk({ + type: 'order', + data: { statuses: [{ error: 'Order must have minimum value of $10.' }] }, + }) + const orders = createOrders({ + info: stubInfo(), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: 'USDH/USDC', side: 'buy', size: '1', price: '1' }), + ).rejects.toMatchObject({ + name: 'NetworkError', + message: /minimum value of \$10/, + }) + }) + + it('surfaces a top-level err from the exchange', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeErr('rate limited'), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect( + orders.placeOrder({ pair: 'USDH/USDC', side: 'buy', size: '1', price: '1' }), + ).rejects.toThrow(/exchange error: rate limited/) + }) +}) + +describe('cancelOrder', () => { + it('cancels by oid using a live pair name', async () => { + const exchange = exchangeOk({ type: 'cancel', data: { statuses: ['success'] } }) + const orders = createOrders({ + info: stubInfo(), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + const result = await orders.cancelOrder({ pair: '@232', oid: 12345 }) + expect(result).toEqual({ oid: 12345 }) + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toEqual({ + type: 'cancel', + cancels: [{ a: 10_232, o: 12345 }], + }) + }) + + it('cancels by oid using a token-pair alias', async () => { + const exchange = exchangeOk({ type: 'cancel', data: { statuses: ['success'] } }) + const orders = createOrders({ + info: stubInfo(), + exchange, + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await orders.cancelOrder({ pair: 'HYPE/USDH', oid: 12346 }) + const [submitArgs] = (exchange.submit as ReturnType).mock.calls[0] ?? [] + expect(submitArgs?.action).toEqual({ + type: 'cancel', + cancels: [{ a: 10_232, o: 12346 }], + }) + }) + + it('rejects a non-USDH pair', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'cancel', data: { statuses: ['success'] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect(orders.cancelOrder({ pair: 'HYPE/USDC', oid: 1 })).rejects.toThrow( + /USDH as base or quote/, + ) + }) + + it('throws on a non-success cancel status', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ + type: 'cancel', + data: { statuses: [{ error: 'Order was never placed, already canceled, or filled.' }] }, + }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect(orders.cancelOrder({ pair: 'USDH/USDC', oid: 1 })).rejects.toThrow( + /cancel error: Order was never placed/, + ) + }) + + it('rejects a negative oid', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({ type: 'cancel', data: { statuses: ['success'] } }), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect(orders.cancelOrder({ pair: 'USDH/USDC', oid: -1 })).rejects.toBeInstanceOf( + InvalidInputError, + ) + }) +}) + +describe('getOpenOrders / getOrderStatus', () => { + it('filters getOpenOrders to USDH-bearing spot orders', async () => { + const usdhBase = openOrder('@230', 1) + const usdhQuoteAlias = openOrder('HYPE/USDH', 2) + const nonUsdhSpot = openOrder('@999', 3) + const frontendOpenOrders = vi.fn(async () => [usdhBase, usdhQuoteAlias, nonUsdhSpot]) + const orders = createOrders({ + info: stubInfo({ frontendOpenOrders }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOpenOrders()).resolves.toEqual([usdhBase, usdhQuoteAlias]) + expect(frontendOpenOrders).toHaveBeenCalledWith(ACCOUNT) + }) + + it('filters getOpenOrders to one USDH pair when requested', async () => { + const usdhBase = openOrder('@230', 1) + const usdhQuoteAlias = openOrder('HYPE/USDH', 2) + const frontendOpenOrders = vi.fn(async () => [usdhBase, usdhQuoteAlias]) + const orders = createOrders({ + info: stubInfo({ frontendOpenOrders }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOpenOrders({ pair: 'USDH/USDC' })).resolves.toEqual([usdhBase]) + await expect(orders.getOpenOrders({ pair: '@232' })).resolves.toEqual([usdhQuoteAlias]) + }) + + it('fetches getOrderStatus by oid after resolving the requested USDH pair', async () => { + const orderStatusClient = vi.fn(async () => orderStatus('@230', 42)) + const orders = createOrders({ + info: stubInfo({ orderStatus: orderStatusClient }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOrderStatus({ pair: 'USDH/USDC', oid: 42 })).resolves.toEqual( + orderStatus('@230', 42), + ) + expect(orderStatusClient).toHaveBeenCalledWith(ACCOUNT, 42) + }) + + it('rejects getOrderStatus when the order belongs to a different USDH pair', async () => { + const orders = createOrders({ + info: stubInfo({ orderStatus: vi.fn(async () => orderStatus('@232', 42)) }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOrderStatus({ pair: '@230', oid: 42 })).rejects.toBeInstanceOf( + InvalidInputError, + ) + }) + + it('rejects getOrderStatus when the order is not a USDH pair', async () => { + const orders = createOrders({ + info: stubInfo({ orderStatus: vi.fn(async () => orderStatus('@999', 42)) }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOrderStatus({ pair: '@230', oid: 42 })).rejects.toThrow( + /is not on USDH pair/, + ) + }) + + it('returns unknownOid without pair ownership checks', async () => { + const orderStatusClient = vi.fn(async () => ({ status: 'unknownOid' }) as const) + const orders = createOrders({ + info: stubInfo({ orderStatus: orderStatusClient }), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + + await expect(orders.getOrderStatus({ pair: '@230', oid: 42 })).resolves.toEqual({ + status: 'unknownOid', + }) + }) + + it('rejects a negative oid in getOrderStatus', async () => { + const orders = createOrders({ + info: stubInfo(), + exchange: exchangeOk({}), + signer: stubSigner(), + network: 'mainnet', + accountAddress: ACCOUNT, + nextNonce: nonceFactory(), + }) + await expect(orders.getOrderStatus({ pair: '@230', oid: -5 })).rejects.toBeInstanceOf( + InvalidInputError, + ) + }) +}) diff --git a/packages/sdk/test/outcomes.test.ts b/packages/sdk/test/outcomes.test.ts index 9d41d40..2c0138e 100644 --- a/packages/sdk/test/outcomes.test.ts +++ b/packages/sdk/test/outcomes.test.ts @@ -39,6 +39,8 @@ function stubInfo(overrides: Partial = {}): InfoClient { l2Book: vi.fn(async () => sampleBook), spotClearinghouseState: vi.fn(), allMids: vi.fn(async () => ({ '#200': '0.733315', '#201': '0.266685', BTC: '80657' })), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), ...overrides, } } diff --git a/packages/sdk/test/pair-resolver.test.ts b/packages/sdk/test/pair-resolver.test.ts index 1f69936..bcca3ba 100644 --- a/packages/sdk/test/pair-resolver.test.ts +++ b/packages/sdk/test/pair-resolver.test.ts @@ -112,6 +112,8 @@ describe('createPairResolver', () => { l2Book: vi.fn(), spotClearinghouseState: vi.fn(), allMids: vi.fn(), + frontendOpenOrders: vi.fn(), + orderStatus: vi.fn(), } } diff --git a/packages/widget/src/friendly-error.ts b/packages/widget/src/friendly-error.ts index 55d8796..e496aa7 100644 --- a/packages/widget/src/friendly-error.ts +++ b/packages/widget/src/friendly-error.ts @@ -15,7 +15,7 @@ const VIEM_USER_REJECTED_NAMES = new Set([ 'UserRejectedRequestErrorType', ]) -const HL_PROTOCOL_PREFIX = /^(HL error|exchange error|order error)/i +const HL_PROTOCOL_PREFIX = /^(HL error|exchange error|order error|cancel error)/i /** * Map an unknown error from the SDK or the wallet provider into a short,