diff --git a/packages/sdk/README.md b/packages/sdk/README.md index e79216a..b60144a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -201,6 +201,139 @@ const outcomeMids = await kit.getOutcomeMids() console.log(market.name, yesBook.coin, outcomeMids[market.sides[0].coin]) ``` +## Experimental HIP-4 builder helpers + +The SDK also exports public, experimental, headless helpers for app builders. +They do not render React components; they turn raw SDK reads into UI-ready data +contracts for HIP-4 event cards, market rows, side selectors, order books, +positions, plus USDH quote guards and order drafts. + +These helpers are additive and read-only/draft-only. Until `1.0.0`, treat the +exact return shapes as pre-release API, but prefer them over app-local parsing: +they centralize side-coin encoding, quote health checks, decimal-safe position +math, and signer-ready order draft validation. +The core builder examples are mirrored in SDK tests so package examples fail +fast if helper signatures drift. + +```ts +import { + createInfoClient, + createOutcomeEventData, + createOutcomeMarketRows, + createOutcomeOrderBookLevels, + createOutcomeOrderBookSummary, + createOutcomePositionData, + createOutcomePositionDataFromSide, + createOutcomePositionRows, + createOutcomeSideSelection, + createQuoteReadiness, + createQuoteSummaryData, + createSpotOrderDraft, + resolveOutcomeMarketSide, +} from '@usdh-kit/sdk' + +const info = createInfoClient({ network: 'mainnet' }) +const [pair] = await kit.listPairs() +const pairBook = pair ? await kit.getBook(pair.name, { nSigFigs: 5 }) : null +const readiness = createQuoteReadiness({ + pair, + book: pairBook, + maxSpreadBps: 10, + minSideDepth: 1000, +}) +const quoteSummary = createQuoteSummaryData({ + pair, + book: pairBook, + amount: '250', + payAsset: 'USDC', + maxSpreadBps: 10, + minSideDepth: 1000, +}) + +const markets = await kit.listOutcomeMarkets() +const [market] = markets +const yesBook = await kit.getOutcomeBook({ outcome: market.outcome, side: 0, nSigFigs: 5 }) +const noBook = await kit.getOutcomeBook({ outcome: market.outcome, side: 1, nSigFigs: 5 }) +const outcomeMids = await kit.getOutcomeMids() +const accountState = await info.spotClearinghouseState(accountAddress) + +const event = createOutcomeEventData(market, [{ book: yesBook }, { book: noBook }]) +const marketRows = createOutcomeMarketRows({ + markets, + readsByCoin: { + [market.sides[0].coin]: { book: yesBook }, + [market.sides[1].coin]: { book: noBook }, + }, +}) +const selectedSide = createOutcomeSideSelection({ + market, + selected: market.sides[0].coin, + reads: [{ book: yesBook }, { book: noBook }], +}) +const sideBook = createOutcomeOrderBookLevels(yesBook) +const sideBookSummary = createOutcomeOrderBookSummary(yesBook) +const position = createOutcomePositionData({ + market, + side: market.sides[0].coin, + quantity: '125.0', +}) +const held = createOutcomePositionDataFromSide({ + markets, + side: '#201', + quantity: '4.2', +}) +const resolvedSide = resolveOutcomeMarketSide([market], '+200') +const portfolioRows = createOutcomePositionRows({ + markets, + balances: accountState.balances, + marks: outcomeMids, +}) + +const ticket = createSpotOrderDraft({ + pair, + side: 'buy', + size: '25', + price: readiness.bestAsk, + readiness, + sizeDecimals: 2, + priceDecimals: 6, + minNotional: 10, + availableQuote: '100', +}) +``` + +Use these helpers when building custom prediction-market UI on top of HIP-4: +the parent app still owns routing, refresh intervals, wallet state, and any +write boundary. `createSpotOrderDraft()` returns checks and a `placeOrderInput` +shape for a wallet-gated handoff, but it never signs or submits. It can also +validate draft-only concerns such as TIF, slippage, precision, minimum size, +minimum notional, and available balance before the signer path is enabled. +HIP-4 helpers are read-only in this release; the SDK order layer still scopes +signed order methods to USDH-bearing spot pairs. + +Builder flow for HIP-4 apps: + +1. Read `outcomeMeta()` or `kit.listOutcomeMarkets()`, then normalize markets. +2. Render discovery/feed rows with `createOutcomeMarketRows()`. +3. Render market cards with `createOutcomeEventData()`. +4. Resolve controlled side selection with `createOutcomeSideSelection()`. +5. Inspect side liquidity with `getOutcomeBook()` plus `createOutcomeOrderBookSummary()`. +6. Resolve account balances into readable positions with `createOutcomePositionRows()`. +7. Keep routing, cache freshness, wallet state, PnL, settlement, and any writes in the parent app. + +Package boundaries: + +| Layer | Import today | Owns | +| --- | --- | --- | +| `@usdh-kit/sdk` | Read clients, USDH spot discovery, HIP-4 metadata, order methods, and builder data helpers. | Typed reads, normalization, checks, and signer-ready input shapes. | +| `@usdh-kit/widget` | The drop-in USDH swap widget. | A packaged swap UI with wallet-gated writes. | +| `apps/demo` registry | Copy/paste React patterns only. | Example component composition, docs, and visual states. | +| Your app | Your product shell. | Routing, cache policy, wallet/session state, balances, PnL, settlement, and final writes. | + +No React hooks or HIP-4 UI package is published in this release. A future +`@usdh-kit/react` package, if added, should stay hooks-only with optional cache +adapters and no bundled visual design system. + ## Trade USDH spot pairs The order layer is scoped to USDH-bearing spot pairs. `pair` accepts the live diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5c88466..9692380 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -41,7 +41,7 @@ "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json" }, "dependencies": { "@noble/hashes": "1.6.1" diff --git a/packages/sdk/src/builder-patterns.ts b/packages/sdk/src/builder-patterns.ts new file mode 100644 index 0000000..74815bd --- /dev/null +++ b/packages/sdk/src/builder-patterns.ts @@ -0,0 +1,1375 @@ +import type { UsdhPair } from './discovery.js' +import { InvalidInputError } from './errors.js' +import type { OrderSide, PlaceOrderInput, Tif } from './orders.js' +import type { OutcomeSide, OutcomeSideMarket, UsdhOutcomeMarket } from './outcomes.js' +import type { L2Book } from './transport/types.js' + +export type QuoteReadinessCheckKey = 'pair' | 'book' | 'spread' | 'depth' +export type QuoteReadinessBlockReason = + | 'missing_pair' + | 'empty_book' + | 'crossed_book' + | 'wide_spread' + | 'thin_depth' + +export interface QuoteReadinessCheck { + key: QuoteReadinessCheckKey + label: string + ready: boolean + value: string + reason?: string +} + +export interface QuoteReadiness { + pair: string + ready: boolean + checks: QuoteReadinessCheck[] + bestBid?: string + bestAsk?: string + spreadBps?: number + depth: { + levels: number + bid: string + ask: string + } + blockReason?: QuoteReadinessBlockReason +} + +export type QuoteSummaryBlockReason = + | QuoteReadinessBlockReason + | 'missing_amount' + | 'unsupported_route' + +export interface QuoteReadinessPairInput { + name: string + base?: string + quote?: string + label?: string +} + +export interface CreateQuoteReadinessInput { + /** USDH pair object, pair label such as "USDH/USDC", or null while loading. */ + pair?: QuoteReadinessPairInput | UsdhPair | string | null + /** Top-of-book read for the selected pair. Null/undefined produces an empty-book block. */ + book?: L2Book | null + /** Maximum acceptable spread in basis points. Defaults to 50 bps. */ + maxSpreadBps?: number + /** Minimum bid and ask side depth across the inspected top levels. */ + minSideDepth?: number | string + /** Number of book levels to include when checking depth. Defaults to 3. */ + depthLevels?: number +} + +export interface CreateQuoteSummaryDataInput extends CreateQuoteReadinessInput { + /** User-entered pay amount as a decimal string. */ + amount?: string | null + /** Asset the user pays, usually USDC or USDH. */ + payAsset?: string + /** Optional expected receive asset. Used to disambiguate route direction. */ + receiveAsset?: string + /** Decimal places for the displayed receive estimate. Defaults to 6. */ + receiveDecimals?: number +} + +export interface QuoteSummaryData { + pair: string + ready: boolean + readiness: QuoteReadiness + pay: { + asset: string + amount: string + } + receive?: { + asset: string + amount: string + } + side?: OrderSide + price?: string + blockReason?: QuoteSummaryBlockReason +} + +export type SpotOrderDraftMode = 'limit' | 'market' +export type SpotOrderDraftCheckKey = + | 'pair' + | 'side' + | 'size' + | 'price' + | 'tif' + | 'slippage' + | 'notional' + | 'balance' + | 'readiness' +export type SpotOrderDraftBlockReason = + | 'missing_pair' + | 'invalid_side' + | 'invalid_size' + | 'below_min_size' + | 'size_precision' + | 'invalid_price' + | 'price_precision' + | 'invalid_tif' + | 'invalid_slippage' + | 'below_min_notional' + | 'insufficient_balance' + | 'quote_not_ready' + +export interface SpotOrderDraftCheck { + key: SpotOrderDraftCheckKey + label: string + ready: boolean + value: string + reason?: string + blockReason?: SpotOrderDraftBlockReason +} + +export interface CreateSpotOrderDraftInput { + /** USDH spot pair object or label. This helper does not support HIP-4 side-coin writes. */ + pair?: QuoteReadinessPairInput | UsdhPair | string | null + side?: OrderSide | string | null + /** Base size as a decimal string. */ + size?: string | null + /** Limit price. Omit for a market-order draft. */ + price?: string | null + mode?: SpotOrderDraftMode + tif?: Tif + reduceOnly?: boolean + slippageBps?: number + minSize?: number | string + minNotional?: number | string + sizeDecimals?: number + priceDecimals?: number + availableBase?: number | string + availableQuote?: number | string + readiness?: QuoteReadiness | null +} + +export interface SpotOrderDraft { + pair: string + side: OrderSide | 'missing' + mode: SpotOrderDraftMode + canReview: boolean + checks: SpotOrderDraftCheck[] + size?: string + price?: string + notional?: string + placeOrderInput?: PlaceOrderInput + blockReason?: SpotOrderDraftBlockReason +} + +export type OrderTicketMode = SpotOrderDraftMode +export type OrderTicketCheckKey = SpotOrderDraftCheckKey +export type OrderTicketBlockReason = SpotOrderDraftBlockReason +export type OrderTicketCheck = SpotOrderDraftCheck +export type CreateOrderTicketDraftInput = CreateSpotOrderDraftInput +export type OrderTicketDraft = SpotOrderDraft + +export interface OutcomeBookInput { + levels: [Array<{ px: string; sz: string }>, Array<{ px: string; sz: string }>] +} + +export interface OutcomeSideReadInput { + /** Optional l2Book for the side coin, used for probability and depth. */ + book?: OutcomeBookInput | null + /** Explicit probability override in whole percent points. */ + probability?: number | null + bestBid?: string | null + bestAsk?: string | null + depth?: string | null +} + +export interface OutcomeSideQuote { + side: OutcomeSide + label: string + coin: `#${number}` + tokenName: `+${number}` + probability: number | null + bestBid?: string + bestAsk?: string + depth?: string +} + +export interface OutcomeEventData { + id: number + title: string + subtitle: string + description: string + descriptionFields: Record + sides: [OutcomeSideQuote, OutcomeSideQuote] +} + +export interface OutcomeBookRow { + price: string + size: string + depthPct: number +} + +export interface OutcomeOrderBookLevels { + bids: OutcomeBookRow[] + asks: OutcomeBookRow[] +} + +export type OutcomeOrderBookBlockReason = 'empty_book' | 'crossed_book' + +export interface OutcomeOrderBookSummary { + coin: string + ready: boolean + levels: OutcomeOrderBookLevels + bestBid?: string + bestAsk?: string + spreadBps?: number + depth: { + levels: number + bid: string + ask: string + minSide: string + } + blockReason?: OutcomeOrderBookBlockReason +} + +export interface CreateOutcomeMarketRowsInput { + markets: UsdhOutcomeMarket[] + /** Optional side reads keyed by #coin, +tokenName, encoding, or asset id. */ + readsByCoin?: Record + limit?: number + sortBy?: 'input' | 'probability' +} + +export interface CreateOutcomeSideSelectionInput { + market: UsdhOutcomeMarket + selected?: OutcomeSide | `#${number}` | `+${number}` | OutcomeSideMarket | null + reads?: [OutcomeSideReadInput?, OutcomeSideReadInput?] +} + +export interface OutcomeSideSelectionData { + event: OutcomeEventData + selectedIndex: OutcomeSide + selectedCoin: `#${number}` + selected: OutcomeSideQuote +} + +export type OutcomePositionState = 'held' | 'watch' | 'settled' | 'redeemable' + +export interface OutcomePositionData { + market: string + outcome: number + side: OutcomeSide + sideName: string + coin: `#${number}` + tokenName: `+${number}` + quantity?: string + mark?: string + state: OutcomePositionState +} + +export type OutcomePositionSideInput = number | `#${number}` | `+${number}` | OutcomeSideMarket + +export interface OutcomeMarketSideResolution { + market: UsdhOutcomeMarket + side: OutcomeSideMarket +} + +export interface OutcomePositionBalanceInput { + /** Hyperliquid balance coin, commonly + for HIP-4 spot balances. */ + coin?: string + /** Hyperliquid token/asset id, matched against normalized side asset ids. */ + token?: number + total: string + hold?: string +} + +export interface CreateOutcomePositionDataInput { + market: UsdhOutcomeMarket + side: OutcomeSide | `#${number}` | `+${number}` | OutcomeSideMarket + quantity?: string + mark?: string + state?: OutcomePositionState +} + +export interface CreateOutcomePositionDataFromSideInput { + markets: UsdhOutcomeMarket[] + side: OutcomePositionSideInput + quantity?: string + mark?: string + state?: OutcomePositionState +} + +export interface CreateOutcomePositionRowsInput { + markets: UsdhOutcomeMarket[] + /** spotClearinghouseState balances or equivalent account balances. */ + balances: OutcomePositionBalanceInput[] + /** Optional marks keyed by #coin, +tokenName, encoding, or asset id. */ + marks?: Record + includeZero?: boolean + state?: OutcomePositionState +} + +const DEFAULT_MAX_SPREAD_BPS = 50 +const DEFAULT_DEPTH_LEVELS = 3 +const DEFAULT_MIN_ORDER_NOTIONAL = 10 +const DECIMAL_STRING_PATTERN = /^\d+(\.\d+)?$/ +const VALID_TIFS = ['Gtc', 'Ioc', 'Alo'] + +interface DecimalValue { + units: bigint + scale: number +} + +/** + * Derive a read-only quote guard from a USDH spot pair and l2Book. + * + * Use this near quote buttons, swap forms, and ticket headers. It never fetches, + * signs, or submits; the parent app owns cache, refresh, wallet, and writes. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createQuoteReadiness(input: CreateQuoteReadinessInput): QuoteReadiness { + const pair = + input.pair === undefined || input.pair === null ? null : normalizePairLabel(input.pair) + const depthLevels = input.depthLevels ?? DEFAULT_DEPTH_LEVELS + const maxSpreadBps = input.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS + const minSideDepth = input.minSideDepth === undefined ? null : Number(input.minSideDepth) + const bids = input.book?.levels[0] ?? [] + const asks = input.book?.levels[1] ?? [] + const bestBid = bids[0]?.px + const bestAsk = asks[0]?.px + const bid = bestBid === undefined ? null : Number(bestBid) + const ask = bestAsk === undefined ? null : Number(bestAsk) + const hasBook = bid !== null && ask !== null && Number.isFinite(bid) && Number.isFinite(ask) + const crossed = hasBook && bid > ask + const spreadBps = hasBook && !crossed ? calculateSpreadBps(bid, ask) : undefined + const bidDepth = sumBookSize(bids.slice(0, depthLevels)) + const askDepth = sumBookSize(asks.slice(0, depthLevels)) + const hasDepth = + hasBook && + (minSideDepth === null || + (!Number.isNaN(minSideDepth) && bidDepth >= minSideDepth && askDepth >= minSideDepth)) + + const checks: QuoteReadinessCheck[] = [ + { + key: 'pair', + label: 'Pair', + ready: pair !== null, + value: pair ?? 'missing', + ...(pair === null && { reason: 'No USDH pair selected.' }), + }, + { + key: 'book', + label: 'Book', + ready: hasBook && !crossed, + value: hasBook ? `${bestBid} / ${bestAsk}` : 'missing', + ...(!hasBook && { reason: 'Both bid and ask are required.' }), + ...(crossed && { reason: 'Best bid is above best ask.' }), + }, + { + key: 'spread', + label: 'Spread', + ready: spreadBps !== undefined && spreadBps <= maxSpreadBps, + value: spreadBps === undefined ? '-' : `${spreadBps.toFixed(1)} bps`, + ...(spreadBps !== undefined && + spreadBps > maxSpreadBps && { reason: `Spread is above ${maxSpreadBps} bps.` }), + }, + { + key: 'depth', + label: 'Depth', + ready: hasDepth, + value: `${formatSize(Math.min(bidDepth, askDepth))} min side`, + ...(!hasDepth && { reason: 'Top-of-book depth is below the requested minimum.' }), + }, + ] + + const blockReason = firstQuoteBlockReason(checks) + return { + pair: pair ?? 'missing', + ready: blockReason === undefined, + checks, + ...(bestBid !== undefined && { bestBid }), + ...(bestAsk !== undefined && { bestAsk }), + ...(spreadBps !== undefined && { spreadBps }), + depth: { + levels: depthLevels, + bid: formatSize(bidDepth), + ask: formatSize(askDepth), + }, + ...(blockReason !== undefined && { blockReason }), + } +} + +/** + * Build a swap-summary data contract from amount, route direction, and top-of-book. + * + * This estimates the receive amount from the current best bid/ask and carries + * the full readiness object so UI can block unsafe quote states. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createQuoteSummaryData(input: CreateQuoteSummaryDataInput): QuoteSummaryData { + const readiness = createQuoteReadiness(input) + const amount = input.amount === undefined || input.amount === null ? null : input.amount + const amountValue = readPositiveDecimal(amount) + const pairAssets = readPairAssets(input.pair) + const route = resolveQuoteRoute(pairAssets, input.payAsset, input.receiveAsset) + const price = route?.side === 'buy' ? readiness.bestAsk : readiness.bestBid + const priceValue = readPositiveDecimal(price) + const receiveAmount = + amountValue !== null && route !== null && priceValue !== null + ? route.side === 'buy' + ? divideDecimalsToString(amountValue, priceValue, input.receiveDecimals ?? 6) + : multiplyDecimalsToString(amountValue, priceValue, input.receiveDecimals ?? 6) + : null + const blockReason: QuoteSummaryBlockReason | undefined = + amountValue === null + ? 'missing_amount' + : route === null + ? 'unsupported_route' + : !readiness.ready + ? readiness.blockReason + : undefined + + return { + pair: readiness.pair, + ready: blockReason === undefined, + readiness, + pay: { + asset: route?.payAsset ?? input.payAsset ?? pairAssets?.quote ?? 'unknown', + amount: amount === null ? 'missing' : cleanDecimalString(amount), + }, + ...(route !== null && + receiveAmount !== null && { + receive: { + asset: route.receiveAsset, + amount: receiveAmount, + }, + }), + ...(route !== null && { side: route.side }), + ...(price !== undefined && { price }), + ...(blockReason !== undefined && { blockReason }), + } +} + +/** + * Validate an unsigned USDH spot order draft and return signer-ready input. + * + * This helper is intentionally draft-only: it does not sign, submit, or call + * /exchange. HIP-4 side-coin trading writes are outside this release scope. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createSpotOrderDraft(input: CreateSpotOrderDraftInput): SpotOrderDraft { + const mode = + input.mode ?? (input.price === undefined || input.price === null ? 'market' : 'limit') + const pair = + input.pair === undefined || input.pair === null ? null : normalizePairLabel(input.pair) + const side = normalizeOrderSide(input.side) + const sizeValue = readPositiveDecimal(input.size) + const priceValue = readPositiveDecimal(input.price) + const sizeDecimalCount = + input.size === undefined || input.size === null ? null : countDecimals(input.size) + const priceDecimalCount = + input.price === undefined || input.price === null ? null : countDecimals(input.price) + const minSize = readNonNegativeDecimal(input.minSize ?? 0) + const impliedPrice = impliedTicketPrice(side, input.readiness) + const notionalPrice = priceValue ?? impliedPrice + const notional = + sizeValue !== null && notionalPrice !== null ? multiplyDecimals(sizeValue, notionalPrice) : null + const minNotional = readPositiveDecimal(input.minNotional ?? DEFAULT_MIN_ORDER_NOTIONAL) + const sizeReady = sizeValue !== null + const sizeReason = + sizeValue === null + ? 'Size must be a positive decimal string.' + : minSize !== null && compareDecimals(sizeValue, minSize) < 0 + ? `Size is below ${formatNotional(minSize)}.` + : input.sizeDecimals !== undefined && + sizeDecimalCount !== null && + sizeDecimalCount > input.sizeDecimals + ? `Size has more than ${input.sizeDecimals} decimals.` + : undefined + const priceReason = + mode === 'limit' && priceValue === null + ? 'Limit price must be a positive decimal string.' + : mode === 'limit' && + input.priceDecimals !== undefined && + priceDecimalCount !== null && + priceDecimalCount > input.priceDecimals + ? `Price has more than ${input.priceDecimals} decimals.` + : undefined + const tifReason = ticketTifReason(mode, input.tif) + const slippageReason = ticketSlippageReason(mode, input.slippageBps) + const notionalReady = + notional === null || minNotional === null || compareDecimals(notional, minNotional) >= 0 + const balanceReason = ticketBalanceReason({ + side, + size: sizeValue, + notional, + availableBase: input.availableBase, + availableQuote: input.availableQuote, + }) + const readinessReady = + input.readiness === undefined || input.readiness === null || input.readiness.ready + const checks: SpotOrderDraftCheck[] = [ + { + key: 'pair', + label: 'Pair', + ready: pair !== null, + value: pair ?? 'missing', + ...(pair === null && { + reason: 'Select a USDH-bearing pair before review.', + blockReason: 'missing_pair' as const, + }), + }, + { + key: 'side', + label: 'Side', + ready: side !== null, + value: side ?? 'missing', + ...(side === null && { + reason: `Side must be 'buy' or 'sell'.`, + blockReason: 'invalid_side' as const, + }), + }, + { + key: 'size', + label: 'Size', + ready: sizeReady && sizeReason === undefined, + value: input.size ?? 'missing', + ...(sizeReason !== undefined && { + reason: sizeReason, + blockReason: + sizeValue === null + ? ('invalid_size' as const) + : minSize !== null && compareDecimals(sizeValue, minSize) < 0 + ? ('below_min_size' as const) + : ('size_precision' as const), + }), + }, + { + key: 'price', + label: mode === 'limit' ? 'Limit price' : 'Market price', + ready: priceReason === undefined, + value: mode === 'market' ? 'derived at submit' : (input.price ?? 'missing'), + ...(priceReason !== undefined && { + reason: priceReason, + blockReason: + priceValue === null ? ('invalid_price' as const) : ('price_precision' as const), + }), + }, + { + key: 'tif', + label: 'TIF', + ready: tifReason === undefined, + value: input.tif ?? (mode === 'market' ? 'Ioc' : 'Gtc'), + ...(tifReason !== undefined && { + reason: tifReason, + blockReason: 'invalid_tif' as const, + }), + }, + { + key: 'slippage', + label: 'Slippage', + ready: slippageReason === undefined, + value: input.slippageBps === undefined ? 'default' : `${input.slippageBps} bps`, + ...(slippageReason !== undefined && { + reason: slippageReason, + blockReason: 'invalid_slippage' as const, + }), + }, + { + key: 'notional', + label: 'Notional', + ready: notionalReady, + value: notional === null ? 'estimated at submit' : formatNotional(notional), + ...(!notionalReady && { + reason: `Notional is below ${formatNotional(minNotional ?? DEFAULT_MIN_ORDER_NOTIONAL)}.`, + blockReason: 'below_min_notional' as const, + }), + }, + { + key: 'balance', + label: 'Balance', + ready: balanceReason === undefined, + value: balanceValue(side, input.availableBase, input.availableQuote), + ...(balanceReason !== undefined && { + reason: balanceReason, + blockReason: 'insufficient_balance' as const, + }), + }, + { + key: 'readiness', + label: 'Quote guard', + ready: readinessReady, + value: + input.readiness === undefined || input.readiness === null + ? 'not provided' + : input.readiness.pair, + ...(!readinessReady && { + reason: 'Quote readiness is blocked.', + blockReason: 'quote_not_ready' as const, + }), + }, + ] + const blockReason = firstSpotOrderBlockReason(checks) + const canReview = blockReason === undefined + const placeOrderInput = + canReview && pair !== null && side !== null && sizeValue !== null + ? createPlaceOrderInput(input, pair, side, mode) + : undefined + + return { + pair: pair ?? 'missing', + side: side ?? 'missing', + mode, + canReview, + checks, + ...(input.size !== undefined && + input.size !== null && { size: cleanDecimalString(input.size) }), + ...(mode === 'limit' && + input.price !== undefined && + input.price !== null && { price: cleanDecimalString(input.price) }), + ...(notional !== null && { notional: formatNotional(notional) }), + ...(placeOrderInput !== undefined && { placeOrderInput }), + ...(blockReason !== undefined && { blockReason }), + } +} + +export const createOrderTicketDraft = createSpotOrderDraft + +/** + * Map a normalized HIP-4 market plus optional side reads into event-card data. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeEventData( + market: UsdhOutcomeMarket, + reads: [OutcomeSideReadInput?, OutcomeSideReadInput?] = [], +): OutcomeEventData { + return { + id: market.outcome, + title: market.name, + subtitle: market.sides.map((side) => side.name).join(' / '), + description: market.description, + descriptionFields: market.descriptionFields, + sides: [ + createOutcomeSideQuote(market.sides[0], reads[0]), + createOutcomeSideQuote(market.sides[1], reads[1]), + ], + } +} + +/** + * Map one normalized HIP-4 side and optional book/read data into an odds quote. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeSideQuote( + side: OutcomeSideMarket, + read: OutcomeSideReadInput = {}, +): OutcomeSideQuote { + const bestBid = read.bestBid ?? read.book?.levels[0]?.[0]?.px + const bestAsk = read.bestAsk ?? read.book?.levels[1]?.[0]?.px + const probability = + read.probability ?? + probabilityFromBook({ + ...(bestBid !== undefined && bestBid !== null && { bestBid }), + ...(bestAsk !== undefined && bestAsk !== null && { bestAsk }), + }) + const depth = + read.depth ?? (read.book ? formatSize(sumBookSize(read.book.levels.flat())) : undefined) + return { + side: side.side, + label: side.name, + coin: side.coin, + tokenName: side.tokenName, + probability, + ...(bestBid !== undefined && bestBid !== null && { bestBid }), + ...(bestAsk !== undefined && bestAsk !== null && { bestAsk }), + ...(depth !== undefined && depth !== null && { depth }), + } +} + +/** + * Normalize a side-coin l2Book into bid/ask rows with depth percentages. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeOrderBookLevels( + book: OutcomeBookInput, + options?: { levels?: number }, +): OutcomeOrderBookLevels { + const levels = options?.levels ?? 6 + const rows = [...book.levels[0].slice(0, levels), ...book.levels[1].slice(0, levels)] + const maxSize = Math.max(0, ...rows.map((row) => Number(row.sz))) + return { + bids: book.levels[0].slice(0, levels).map((row) => toOutcomeBookRow(row, maxSize)), + asks: book.levels[1].slice(0, levels).map((row) => toOutcomeBookRow(row, maxSize)), + } +} + +/** + * Build read-only book health for one HIP-4 side coin. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeOrderBookSummary( + book: OutcomeBookInput & { coin?: string }, + options?: { levels?: number }, +): OutcomeOrderBookSummary { + const levels = options?.levels ?? 6 + const bids = book.levels[0] + const asks = book.levels[1] + const bestBid = bids[0]?.px + const bestAsk = asks[0]?.px + const bid = readNonNegativeNumber(bestBid) + const ask = readNonNegativeNumber(bestAsk) + const hasBook = bid !== null && ask !== null + const crossed = hasBook && bid > ask + const spreadBps = hasBook && !crossed ? calculateSpreadBps(bid, ask) : undefined + const bidDepth = sumBookSize(bids.slice(0, levels)) + const askDepth = sumBookSize(asks.slice(0, levels)) + const blockReason: OutcomeOrderBookBlockReason | undefined = !hasBook + ? 'empty_book' + : crossed + ? 'crossed_book' + : undefined + + return { + coin: book.coin ?? 'unknown', + ready: blockReason === undefined, + levels: createOutcomeOrderBookLevels(book, { levels }), + ...(bestBid !== undefined && { bestBid }), + ...(bestAsk !== undefined && { bestAsk }), + ...(spreadBps !== undefined && { spreadBps }), + depth: { + levels, + bid: formatSize(bidDepth), + ask: formatSize(askDepth), + minSide: formatSize(Math.min(bidDepth, askDepth)), + }, + ...(blockReason !== undefined && { blockReason }), + } +} + +/** + * Build market-list rows from normalized HIP-4 metadata and optional side reads. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeMarketRows(input: CreateOutcomeMarketRowsInput): OutcomeEventData[] { + const rows = input.markets.map((market) => { + const reads: [OutcomeSideReadInput?, OutcomeSideReadInput?] = [] + const first = readOutcomeSideInput(input.readsByCoin, market.sides[0]) + const second = readOutcomeSideInput(input.readsByCoin, market.sides[1]) + if (first !== undefined) reads[0] = first + if (second !== undefined) reads[1] = second + return createOutcomeEventData(market, reads) + }) + const sorted = + input.sortBy === 'probability' + ? [...rows].sort((left, right) => topProbability(right) - topProbability(left)) + : rows + return input.limit === undefined ? sorted : sorted.slice(0, input.limit) +} + +/** + * Resolve a controlled selected side coin and return both the event and side data. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomeSideSelection( + input: CreateOutcomeSideSelectionInput, +): OutcomeSideSelectionData { + const selectedSide = resolveOutcomeSide(input.market, input.selected ?? input.market.sides[0]) + const event = createOutcomeEventData(input.market, input.reads) + const selected = event.sides[selectedSide.side] + return { + event, + selectedIndex: selectedSide.side, + selectedCoin: selectedSide.coin, + selected, + } +} + +/** + * Resolve one HIP-4 side reference into a readable portfolio/watchlist row. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomePositionData( + input: CreateOutcomePositionDataInput, +): OutcomePositionData { + const side = resolveOutcomeSide(input.market, input.side) + return { + market: input.market.name, + outcome: input.market.outcome, + side: side.side, + sideName: side.name, + coin: side.coin, + tokenName: side.tokenName, + ...(input.quantity !== undefined && { quantity: input.quantity }), + ...(input.mark !== undefined && { mark: input.mark }), + state: input.state ?? 'held', + } +} + +/** + * Resolve a side coin/token across a market list into one position row. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomePositionDataFromSide( + input: CreateOutcomePositionDataFromSideInput, +): OutcomePositionData { + const resolved = resolveOutcomeMarketSide(input.markets, input.side) + return createOutcomePositionData({ + market: resolved.market, + side: resolved.side, + ...(input.quantity !== undefined && { quantity: input.quantity }), + ...(input.mark !== undefined && { mark: input.mark }), + ...(input.state !== undefined && { state: input.state }), + }) +} + +/** + * Convert wallet balances into readable HIP-4 position rows. + * + * Balances are matched by +tokenName, #coin, encoding, or asset id. Quantity is + * computed as total - hold using decimal-string arithmetic to avoid float drift. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function createOutcomePositionRows( + input: CreateOutcomePositionRowsInput, +): OutcomePositionData[] { + const rows: OutcomePositionData[] = [] + for (const balance of input.balances) { + const resolved = findOutcomeMarketSideFromBalance(input.markets, balance) + if (resolved === null) continue + const quantity = subtractDecimalStrings(balance.total, balance.hold ?? '0') + if (!input.includeZero && isZeroDecimalString(quantity)) continue + const mark = readOutcomeMark(input.marks, resolved.side) + rows.push( + createOutcomePositionData({ + market: resolved.market, + side: resolved.side, + quantity, + ...(mark !== undefined && { mark }), + state: input.state ?? 'held', + }), + ) + } + return rows +} + +/** + * Find a HIP-4 side in a market list without throwing. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function findOutcomeMarketSide( + markets: UsdhOutcomeMarket[], + side: OutcomePositionSideInput, +): OutcomeMarketSideResolution | null { + const ref = normalizeOutcomePositionSideRef(side) + for (const market of markets) { + const found = market.sides.find( + (candidate) => + candidate.coin === ref || + candidate.tokenName === ref || + candidate.encoding === ref || + candidate.assetId === ref, + ) + if (found !== undefined) return { market, side: found } + } + return null +} + +/** + * Find a HIP-4 side in a market list or throw InvalidInputError. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function resolveOutcomeMarketSide( + markets: UsdhOutcomeMarket[], + side: OutcomePositionSideInput, +): OutcomeMarketSideResolution { + const resolved = findOutcomeMarketSide(markets, side) + if (resolved === null) { + throw new InvalidInputError( + `outcome side ${String(normalizeOutcomePositionSideRef(side))} was not found`, + ) + } + return resolved +} + +/** + * Resolve a HIP-4 side from a spot balance coin/token without throwing. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function findOutcomeMarketSideFromBalance( + markets: UsdhOutcomeMarket[], + balance: OutcomePositionBalanceInput, +): OutcomeMarketSideResolution | null { + for (const ref of outcomeSideRefsFromBalance(balance)) { + const resolved = findOutcomeMarketSide(markets, ref) + if (resolved !== null) return resolved + } + return null +} + +/** + * Resolve a side index, #coin, +tokenName, or side object within one market. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function resolveOutcomeSide( + market: UsdhOutcomeMarket, + side: OutcomeSide | `#${number}` | `+${number}` | OutcomeSideMarket, +): OutcomeSideMarket { + if (typeof side === 'object') return side + const found = + typeof side === 'number' + ? market.sides[side] + : market.sides.find((candidate) => candidate.coin === side || candidate.tokenName === side) + if (found === undefined) { + throw new InvalidInputError( + `outcome side ${String(side)} was not found for outcome ${market.outcome}`, + ) + } + return found +} + +/** + * Estimate whole-percent probability from best bid/ask or a side-coin l2Book. + * + * @experimental Builder helper API is pre-release until `1.0.0`. + */ +export function probabilityFromBook( + input: OutcomeBookInput | { bestBid?: string | null; bestAsk?: string | null }, +): number | null { + const bestBid = 'levels' in input ? input.levels[0][0]?.px : input.bestBid + const bestAsk = 'levels' in input ? input.levels[1][0]?.px : input.bestAsk + const bid = bestBid === undefined || bestBid === null ? Number.NaN : Number(bestBid) + const ask = bestAsk === undefined || bestAsk === null ? Number.NaN : Number(bestAsk) + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid < 0 || ask < 0 || bid > ask) { + return null + } + return Math.min(99, Math.max(1, Math.round(((bid + ask) / 2) * 100))) +} + +function normalizeOutcomePositionSideRef( + side: OutcomePositionSideInput, +): `#${number}` | `+${number}` | number { + if (typeof side === 'object') return side.coin + return side +} + +function outcomeSideRefsFromBalance( + balance: OutcomePositionBalanceInput, +): OutcomePositionSideInput[] { + const refs: OutcomePositionSideInput[] = [] + if (balance.coin?.startsWith('#') || balance.coin?.startsWith('+')) { + refs.push(balance.coin as `#${number}` | `+${number}`) + } + if (balance.token !== undefined) { + refs.push(balance.token) + } + return refs +} + +function readOutcomeMark( + marks: Record | undefined, + side: OutcomeSideMarket, +): string | undefined { + if (marks === undefined) return undefined + return ( + marks[side.coin] ?? + marks[side.tokenName] ?? + marks[String(side.encoding)] ?? + marks[String(side.assetId)] + ) +} + +function readOutcomeSideInput( + reads: Record | undefined, + side: OutcomeSideMarket, +): OutcomeSideReadInput | undefined { + if (reads === undefined) return undefined + return ( + reads[side.coin] ?? + reads[side.tokenName] ?? + reads[String(side.encoding)] ?? + reads[String(side.assetId)] + ) +} + +function topProbability(event: OutcomeEventData): number { + return Math.max(...event.sides.map((side) => side.probability ?? -1)) +} + +function subtractDecimalStrings(total: string, hold: string): string { + const totalClean = cleanDecimalString(total) + const holdClean = cleanDecimalString(hold) + if (!DECIMAL_STRING_PATTERN.test(totalClean) || !DECIMAL_STRING_PATTERN.test(holdClean)) { + return '0' + } + const scale = Math.max(countDecimals(totalClean), countDecimals(holdClean)) + const diff = parseFixedDecimal(totalClean, scale) - parseFixedDecimal(holdClean, scale) + return formatFixedDecimal(diff > 0n ? diff : 0n, scale) +} + +function isZeroDecimalString(value: string): boolean { + return readNonNegativeNumber(value) === 0 +} + +function parseFixedDecimal(value: string, scale: number): bigint { + const [intPart = '0', fracPart = ''] = value.split('.') + return BigInt(`${intPart}${fracPart.padEnd(scale, '0')}`) +} + +function formatFixedDecimal(value: bigint, scale: number): string { + if (value === 0n) return '0' + if (scale === 0) return value.toString() + const padded = value.toString().padStart(scale + 1, '0') + const whole = padded.slice(0, -scale) + const fraction = padded.slice(-scale).replace(/0+$/, '') + return fraction === '' ? whole : `${whole}.${fraction}` +} + +function normalizePairLabel(pair: QuoteReadinessPairInput | UsdhPair | string): string { + if (typeof pair === 'string') return pair + if ('label' in pair && pair.label) return pair.label + if ('base' in pair && 'quote' in pair && pair.base && pair.quote) + return `${pair.base}/${pair.quote}` + return pair.name +} + +function readPairAssets( + pair: QuoteReadinessPairInput | UsdhPair | string | null | undefined, +): { base: string; quote: string } | null { + if (pair === undefined || pair === null) return null + if (typeof pair === 'string') return parsePairAssets(pair) + if ('base' in pair && 'quote' in pair && pair.base && pair.quote) { + return { base: pair.base, quote: pair.quote } + } + return parsePairAssets(pair.name) +} + +function parsePairAssets(pair: string): { base: string; quote: string } | null { + const [base, quote, extra] = pair.split('/') + if (base === undefined || quote === undefined || extra !== undefined) return null + if (base === '' || quote === '') return null + return { base, quote } +} + +function resolveQuoteRoute( + pair: { base: string; quote: string } | null, + payAsset: string | undefined, + receiveAsset: string | undefined, +): { + payAsset: string + receiveAsset: string + side: OrderSide +} | null { + if (pair === null) return null + const pay = payAsset === undefined ? undefined : normalizeAssetName(payAsset) + const receive = receiveAsset === undefined ? undefined : normalizeAssetName(receiveAsset) + const base = normalizeAssetName(pair.base) + const quote = normalizeAssetName(pair.quote) + + if (pay !== undefined && receive !== undefined) { + if (pay === quote && receive === base) { + return { payAsset: pair.quote, receiveAsset: pair.base, side: 'buy' } + } + if (pay === base && receive === quote) { + return { payAsset: pair.base, receiveAsset: pair.quote, side: 'sell' } + } + return null + } + if (pay !== undefined) { + if (pay === quote) return { payAsset: pair.quote, receiveAsset: pair.base, side: 'buy' } + if (pay === base) return { payAsset: pair.base, receiveAsset: pair.quote, side: 'sell' } + return null + } + if (receive !== undefined) { + if (receive === base) return { payAsset: pair.quote, receiveAsset: pair.base, side: 'buy' } + if (receive === quote) return { payAsset: pair.base, receiveAsset: pair.quote, side: 'sell' } + } + return null +} + +function normalizeAssetName(asset: string): string { + return asset.trim().toUpperCase() +} + +function normalizeOrderSide(side: OrderSide | string | null | undefined): OrderSide | null { + if (side === 'buy' || side === 'sell') return side + return null +} + +function readPositiveDecimal(value: number | string | null | undefined): DecimalValue | null { + if (value === undefined || value === null) return null + const parsed = readDecimal(value) + return parsed !== null && parsed.units > 0n ? parsed : null +} + +function readNonNegativeDecimal(value: number | string | null | undefined): DecimalValue | null { + if (value === undefined || value === null) return null + const parsed = readDecimal(value) + return parsed !== null && parsed.units >= 0n ? parsed : null +} + +function readDecimal(value: number | string): DecimalValue | null { + const cleaned = cleanDecimalString(String(value)) + if (!DECIMAL_STRING_PATTERN.test(cleaned)) return null + const [whole = '0', fraction = ''] = cleaned.split('.') + const scale = fraction.length + return { + units: BigInt(`${whole}${fraction}`), + scale, + } +} + +function readNonNegativeNumber(value: number | string | null | undefined): number | null { + if (value === undefined || value === null) return null + if (typeof value === 'string' && !DECIMAL_STRING_PATTERN.test(cleanDecimalString(value))) { + return null + } + const parsed = typeof value === 'number' ? value : Number(cleanDecimalString(value)) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} + +function cleanDecimalString(value: string): string { + return value.trim().replace(/,/g, '') +} + +function compareDecimals(left: DecimalValue, right: DecimalValue): -1 | 0 | 1 { + const scale = Math.max(left.scale, right.scale) + const leftUnits = scaleDecimalUnits(left, scale) + const rightUnits = scaleDecimalUnits(right, scale) + if (leftUnits < rightUnits) return -1 + if (leftUnits > rightUnits) return 1 + return 0 +} + +function multiplyDecimals(left: DecimalValue, right: DecimalValue): DecimalValue { + return { + units: left.units * right.units, + scale: left.scale + right.scale, + } +} + +function multiplyDecimalsToString( + left: DecimalValue, + right: DecimalValue, + decimals: number, +): string { + return formatDecimal(roundDecimal(multiplyDecimals(left, right), decimals)) +} + +function divideDecimalsToString( + numerator: DecimalValue, + denominator: DecimalValue, + decimals: number, +): string { + if (denominator.units <= 0n) return '-' + const scale = Math.max(0, decimals) + const dividend = numerator.units * pow10(denominator.scale + scale) + const divisor = denominator.units * pow10(numerator.scale) + const quotient = roundQuotient(dividend, divisor) + return formatDecimal({ units: quotient, scale }) +} + +function roundDecimal(value: DecimalValue, decimals: number): DecimalValue { + const scale = Math.max(0, decimals) + if (value.scale <= scale) { + return { units: value.units * pow10(scale - value.scale), scale } + } + const divisor = pow10(value.scale - scale) + return { + units: roundQuotient(value.units, divisor), + scale, + } +} + +function roundQuotient(dividend: bigint, divisor: bigint): bigint { + const quotient = dividend / divisor + const remainder = dividend % divisor + return remainder * 2n >= divisor ? quotient + 1n : quotient +} + +function scaleDecimalUnits(value: DecimalValue, scale: number): bigint { + return value.units * pow10(scale - value.scale) +} + +function pow10(exponent: number): bigint { + if (exponent <= 0) return 1n + return 10n ** BigInt(exponent) +} + +function formatDecimal(value: DecimalValue): string { + const negative = value.units < 0n + const units = negative ? -value.units : value.units + const raw = units.toString().padStart(value.scale + 1, '0') + const whole = value.scale === 0 ? raw : raw.slice(0, -value.scale) + const fraction = value.scale === 0 ? '' : raw.slice(-value.scale).replace(/0+$/, '') + const formatted = fraction.length > 0 ? `${whole}.${fraction}` : whole + return negative ? `-${formatted}` : formatted +} + +function countDecimals(value: string): number { + const cleaned = cleanDecimalString(value) + if (!DECIMAL_STRING_PATTERN.test(cleaned)) return Number.POSITIVE_INFINITY + return cleaned.split('.')[1]?.length ?? 0 +} + +function ticketTifReason(mode: SpotOrderDraftMode, tif: Tif | undefined): string | undefined { + if (tif === undefined) return undefined + if (!VALID_TIFS.includes(tif)) return `TIF must be 'Gtc', 'Ioc', or 'Alo'.` + if (mode === 'market' && tif !== 'Ioc') return 'Market order drafts force TIF to Ioc.' + return undefined +} + +function ticketSlippageReason( + mode: SpotOrderDraftMode, + slippageBps: number | undefined, +): string | undefined { + if (slippageBps === undefined) return undefined + if ( + !Number.isFinite(slippageBps) || + !Number.isInteger(slippageBps) || + slippageBps < 0 || + slippageBps > 10_000 + ) { + return 'Slippage must be an integer in [0, 10000] bps.' + } + if (mode !== 'market') return 'Slippage only applies to market order drafts.' + return undefined +} + +function ticketBalanceReason({ + side, + size, + notional, + availableBase, + availableQuote, +}: { + side: OrderSide | null + size: DecimalValue | null + notional: DecimalValue | null + availableBase: number | string | undefined + availableQuote: number | string | undefined +}): string | undefined { + if (side === 'sell' && availableBase !== undefined && size !== null) { + const balance = readNonNegativeDecimal(availableBase) + if (balance !== null && compareDecimals(balance, size) < 0) { + return 'Base balance is below draft size.' + } + } + if (side === 'buy' && availableQuote !== undefined && notional !== null) { + const balance = readNonNegativeDecimal(availableQuote) + if (balance !== null && compareDecimals(balance, notional) < 0) { + return 'Quote balance is below draft notional.' + } + } + return undefined +} + +function balanceValue( + side: OrderSide | null, + availableBase: number | string | undefined, + availableQuote: number | string | undefined, +): string { + if (side === 'sell' && availableBase !== undefined) return String(availableBase) + if (side === 'buy' && availableQuote !== undefined) return String(availableQuote) + return 'not provided' +} + +function impliedTicketPrice( + side: OrderSide | null, + readiness: QuoteReadiness | null | undefined, +): DecimalValue | null { + if (side === null || readiness === undefined || readiness === null) return null + const price = side === 'buy' ? readiness.bestAsk : readiness.bestBid + return readPositiveDecimal(price) +} + +function formatNotional(value: DecimalValue | number): string { + const decimal = typeof value === 'number' ? readNonNegativeDecimal(value) : value + if (decimal === null) return '-' + return formatDecimal(roundDecimal(decimal, decimal.units >= 100n * pow10(decimal.scale) ? 0 : 2)) +} + +function createPlaceOrderInput( + input: CreateSpotOrderDraftInput, + pair: string, + side: OrderSide, + mode: SpotOrderDraftMode, +): PlaceOrderInput { + return { + pair, + side, + size: cleanDecimalString(input.size as string), + ...(mode === 'limit' && + input.price !== undefined && + input.price !== null && { price: cleanDecimalString(input.price) }), + ...(mode === 'limit' && input.tif !== undefined && { tif: input.tif }), + ...(input.reduceOnly !== undefined && { reduceOnly: input.reduceOnly }), + ...(mode === 'market' && input.slippageBps !== undefined && { slippageBps: input.slippageBps }), + } +} + +function calculateSpreadBps(bid: number, ask: number): number { + const mid = (bid + ask) / 2 + if (mid <= 0) return Number.POSITIVE_INFINITY + return ((ask - bid) / mid) * 10_000 +} + +function sumBookSize(rows: Array<{ sz: string }>): number { + return rows.reduce((total, row) => { + const next = Number(row.sz.replace(/,/g, '')) + return Number.isFinite(next) ? total + next : total + }, 0) +} + +function formatSize(value: number): string { + if (!Number.isFinite(value)) return '0' + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000)}m` + if (value >= 1_000) return `${trimNumber(value / 1_000)}k` + return trimNumber(value) +} + +function trimNumber(value: number): string { + return value + .toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2) + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, '') +} + +function firstQuoteBlockReason( + checks: QuoteReadinessCheck[], +): QuoteReadinessBlockReason | undefined { + const failed = checks.find((check) => !check.ready) + if (failed?.key === 'pair') return 'missing_pair' + if (failed?.key === 'book') + return failed.reason?.includes('above') ? 'crossed_book' : 'empty_book' + if (failed?.key === 'spread') return 'wide_spread' + if (failed?.key === 'depth') return 'thin_depth' + return undefined +} + +function firstSpotOrderBlockReason( + checks: SpotOrderDraftCheck[], +): SpotOrderDraftBlockReason | undefined { + const failed = checks.find((check) => !check.ready) + return failed?.blockReason +} + +function toOutcomeBookRow(row: { px: string; sz: string }, maxSize: number): OutcomeBookRow { + const size = Number(row.sz) + const depthPct = + maxSize > 0 && Number.isFinite(size) ? Math.max(1, Math.round((size / maxSize) * 100)) : 0 + return { + price: row.px, + size: row.sz, + depthPct, + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d2ed27f..7c23335 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,6 +2,68 @@ export { createUsdhKit } from './kit.js' export type { UsdhKit } from './kit.js' export { approveAgent } from './agent.js' export type { ApproveAgentArgs, ApproveAgentResult } from './agent.js' +export { + createOrderTicketDraft, + createOutcomeEventData, + createOutcomeMarketRows, + createOutcomeOrderBookLevels, + createOutcomeOrderBookSummary, + createOutcomePositionData, + createOutcomePositionDataFromSide, + createOutcomePositionRows, + createOutcomeSideSelection, + createOutcomeSideQuote, + createQuoteReadiness, + createQuoteSummaryData, + createSpotOrderDraft, + findOutcomeMarketSide, + findOutcomeMarketSideFromBalance, + probabilityFromBook, + resolveOutcomeMarketSide, + resolveOutcomeSide, +} from './builder-patterns.js' +export type { + CreateOrderTicketDraftInput, + CreateOutcomeMarketRowsInput, + CreateOutcomeSideSelectionInput, + CreateOutcomePositionDataFromSideInput, + CreateOutcomePositionDataInput, + CreateOutcomePositionRowsInput, + CreateQuoteReadinessInput, + CreateQuoteSummaryDataInput, + CreateSpotOrderDraftInput, + OrderTicketBlockReason, + OrderTicketCheck, + OrderTicketCheckKey, + OrderTicketDraft, + OrderTicketMode, + OutcomeBookInput, + OutcomeBookRow, + OutcomeEventData, + OutcomeMarketSideResolution, + OutcomeOrderBookBlockReason, + OutcomeOrderBookLevels, + OutcomeOrderBookSummary, + OutcomePositionBalanceInput, + OutcomePositionData, + OutcomePositionSideInput, + OutcomePositionState, + OutcomeSideSelectionData, + OutcomeSideQuote, + OutcomeSideReadInput, + QuoteReadiness, + QuoteReadinessBlockReason, + QuoteReadinessCheck, + QuoteReadinessCheckKey, + QuoteReadinessPairInput, + QuoteSummaryBlockReason, + QuoteSummaryData, + SpotOrderDraft, + SpotOrderDraftBlockReason, + SpotOrderDraftCheck, + SpotOrderDraftCheckKey, + SpotOrderDraftMode, +} from './builder-patterns.js' export { HYPER_EVM_NATIVE_USDC, getHyperEvmNativeUsdcAddress } from './usdc.js' export { findUsdhSpotPair, listUsdhSpotPairs } from './discovery.js' export type { diff --git a/packages/sdk/test/agent.test.ts b/packages/sdk/test/agent.test.ts index f7267a4..5b86932 100644 --- a/packages/sdk/test/agent.test.ts +++ b/packages/sdk/test/agent.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { approveAgent } from '../src/agent.js' import { InvalidInputError, NetworkError, SigningError } from '../src/errors.js' +import type { SignTypedDataArgs } from '../src/types/signer.js' import type { Signer } from '../src/types/signer.js' +import { mockCallArg } from './test-utils.js' const masterSigner: Signer = { address: '0x0000000000000000000000000000000000000001', @@ -36,22 +38,22 @@ describe('approveAgent', () => { expect(result.agentAddress).toBe('0x00000000000000000000000000000000000000aa') expect(result.agentName).toBe('usdh-kit-session') - const typedData = signTypedData.mock.calls[0]?.[0] - expect(typedData?.domain).toMatchObject({ + const typedData = mockCallArg(signTypedData, 0, 0) + expect(typedData.domain).toMatchObject({ name: 'HyperliquidSignTransaction', version: '1', chainId: 999, verifyingContract: '0x0000000000000000000000000000000000000000', }) - expect(typedData?.primaryType).toBe('HyperliquidTransaction:ApproveAgent') - expect(typedData?.message).toMatchObject({ + expect(typedData.primaryType).toBe('HyperliquidTransaction:ApproveAgent') + expect(typedData.message).toMatchObject({ hyperliquidChain: 'Mainnet', agentAddress: '0x00000000000000000000000000000000000000aa', agentName: 'usdh-kit-session', }) - const init = fetch.mock.calls[0]?.[1] - const body = JSON.parse(init?.body as string) as Record + const init = mockCallArg(fetch, 0, 1) + const body = JSON.parse(init.body as string) as Record expect(body.action).toMatchObject({ type: 'approveAgent', hyperliquidChain: 'Mainnet', @@ -73,10 +75,11 @@ describe('approveAgent', () => { agentAddress: '0x00000000000000000000000000000000000000aa', fetch: fetch as unknown as typeof globalThis.fetch, }) - const typedData = signTypedData.mock.calls[0]?.[0] - expect(typedData?.domain.chainId).toBe(421_614) - expect(typedData?.message.hyperliquidChain).toBe('Testnet') - const body = JSON.parse(fetch.mock.calls[0]?.[1]?.body as string) as Record + const typedData = mockCallArg(signTypedData, 0, 0) + expect(typedData.domain.chainId).toBe(421_614) + expect(typedData.message.hyperliquidChain).toBe('Testnet') + const init = mockCallArg(fetch, 0, 1) + const body = JSON.parse(init.body as string) as Record expect((body.action as { signatureChainId?: string }).signatureChainId).toBe('0x66eee') expect('agentName' in (body.action as Record)).toBe(false) }) diff --git a/packages/sdk/test/builder-patterns.examples.test.ts b/packages/sdk/test/builder-patterns.examples.test.ts new file mode 100644 index 0000000..ce50e38 --- /dev/null +++ b/packages/sdk/test/builder-patterns.examples.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' + +import { + createOutcomeEventData, + createOutcomeMarketRows, + createOutcomeOrderBookSummary, + createOutcomePositionRows, + createOutcomeSideSelection, + createQuoteSummaryData, + createSpotOrderDraft, + normalizeOutcomeMeta, +} from '../src/index.js' +import type { L2Book, OutcomeMeta, SpotBalance } from '../src/transport/types.js' + +const pair = { + kind: 'spot' as const, + name: '@230', + base: 'USDH', + quote: 'USDC', + usdhRole: 'base' as const, + index: 230, + tokens: [150, 0] as [number, number], +} + +const pairBook: L2Book = { + coin: '@230', + time: 1778427457824, + levels: [ + [ + { px: '0.9999', sz: '17000', n: 1 }, + { px: '0.9998', sz: '11000', n: 1 }, + ], + [ + { px: '1.0001', sz: '14000', n: 1 }, + { px: '1.0002', sz: '9000', n: 1 }, + ], + ], +} + +const outcomeMeta: OutcomeMeta = { + outcomes: [ + { + outcome: 20, + name: 'USDH weekly volume clears $5m', + description: 'class:volume|asset:USDH|target:5000000', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + { + outcome: 24, + name: 'HYPE weekly close green', + description: 'class:directional|asset:HYPE|window:weekly', + sideSpecs: [{ name: 'Up' }, { name: 'Down' }], + }, + ], +} + +const yesBook: L2Book = { + coin: '#200', + time: 1778427457824, + levels: [ + [ + { px: '0.69', sz: '250', n: 2 }, + { px: '0.68', sz: '125', n: 1 }, + ], + [ + { px: '0.72', sz: '190', n: 1 }, + { px: '0.73', sz: '90', n: 1 }, + ], + ], +} + +const noBook: L2Book = { + coin: '#201', + time: 1778427457824, + levels: [[{ px: '0.28', sz: '90', n: 1 }], [{ px: '0.31', sz: '120', n: 2 }]], +} + +const balances: SpotBalance[] = [ + { + coin: '+200', + token: 100_000_200, + total: '12.50', + hold: '2.25', + entryNtl: '8.61', + }, + { + coin: 'USDC', + token: 0, + total: '100', + hold: '0', + entryNtl: '0', + }, +] + +describe('builder helper examples', () => { + it('keeps the swap quote summary example executable', () => { + const summary = createQuoteSummaryData({ + pair, + book: pairBook, + amount: '250', + payAsset: 'USDC', + maxSpreadBps: 10, + minSideDepth: 1_000, + }) + + expect(summary.ready).toBe(true) + expect(summary.receive).toEqual({ asset: 'USDH', amount: '249.975002' }) + if (summary.side === undefined || summary.price === undefined) { + throw new Error('expected quote summary to resolve side and price') + } + + const ticket = createSpotOrderDraft({ + pair, + side: summary.side, + size: '25', + price: summary.price, + readiness: summary.readiness, + minNotional: 10, + availableQuote: '100', + }) + + expect(ticket.canReview).toBe(true) + expect(ticket.placeOrderInput).toMatchObject({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1.0001', + }) + }) + + it('keeps the HIP-4 market detail example executable', () => { + const markets = normalizeOutcomeMeta(outcomeMeta) + const [market] = markets + if (market === undefined) throw new Error('missing outcome market') + + const event = createOutcomeEventData(market, [{ book: yesBook }, { book: noBook }]) + const rows = createOutcomeMarketRows({ + markets, + readsByCoin: { + [market.sides[0].coin]: { book: yesBook }, + [market.sides[1].coin]: { book: noBook }, + }, + sortBy: 'probability', + }) + const selected = createOutcomeSideSelection({ + market, + selected: market.sides[0].coin, + reads: [{ book: yesBook }, { book: noBook }], + }) + const bookSummary = createOutcomeOrderBookSummary(yesBook) + + expect(event.sides[0]).toMatchObject({ coin: '#200', probability: 71 }) + expect(rows[0]?.title).toBe('USDH weekly volume clears $5m') + expect(selected.selectedCoin).toBe('#200') + expect(bookSummary.ready).toBe(true) + expect(bookSummary.depth.minSide).toBe('280') + }) + + it('keeps the HIP-4 portfolio example executable', () => { + const positions = createOutcomePositionRows({ + markets: normalizeOutcomeMeta(outcomeMeta), + balances, + marks: { '#200': '71%' }, + }) + + expect(positions).toEqual([ + { + market: 'USDH weekly volume clears $5m', + outcome: 20, + side: 0, + sideName: 'Yes', + coin: '#200', + tokenName: '+200', + quantity: '10.25', + mark: '71%', + state: 'held', + }, + ]) + }) +}) diff --git a/packages/sdk/test/builder-patterns.test.ts b/packages/sdk/test/builder-patterns.test.ts new file mode 100644 index 0000000..a2d35f2 --- /dev/null +++ b/packages/sdk/test/builder-patterns.test.ts @@ -0,0 +1,582 @@ +import { describe, expect, it } from 'vitest' + +import { InvalidInputError } from '../src/errors.js' +import { + createOrderTicketDraft, + createOutcomeEventData, + createOutcomeMarketRows, + createOutcomeOrderBookLevels, + createOutcomeOrderBookSummary, + createOutcomePositionData, + createOutcomePositionDataFromSide, + createOutcomePositionRows, + createOutcomeSideSelection, + createQuoteReadiness, + createQuoteSummaryData, + createSpotOrderDraft, + findOutcomeMarketSide, + findOutcomeMarketSideFromBalance, + normalizeOutcomeMeta, + probabilityFromBook, + resolveOutcomeMarketSide, + resolveOutcomeSide, +} from '../src/index.js' +import type { UsdhOutcomeMarket } from '../src/outcomes.js' +import type { L2Book, OutcomeMeta } from '../src/transport/types.js' + +const sampleMeta: OutcomeMeta = { + outcomes: [ + { + outcome: 20, + name: 'BTC closes above 100k Friday', + description: 'class:priceBinary|underlying:BTC|targetPrice:100000', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + { + outcome: 24, + name: 'HYPE weekly close green', + description: 'class:directional|underlying:HYPE|window:weekly', + sideSpecs: [{ name: 'Up' }, { name: 'Down' }], + }, + ], +} + +const usdhBook: L2Book = { + coin: '@230', + time: 1778427457824, + levels: [ + [ + { px: '0.9999', sz: '17000', n: 1 }, + { px: '0.9998', sz: '11000', n: 1 }, + ], + [ + { px: '1.0001', sz: '14000', n: 1 }, + { px: '1.0002', sz: '9000', n: 1 }, + ], + ], +} + +const outcomeBook: L2Book = { + coin: '#200', + time: 1778427457824, + levels: [ + [ + { px: '0.69', sz: '100', n: 1 }, + { px: '0.68', sz: '50', n: 1 }, + ], + [ + { px: '0.72', sz: '25', n: 1 }, + { px: '0.73', sz: '10', n: 1 }, + ], + ], +} + +function sampleMarkets(): [UsdhOutcomeMarket, UsdhOutcomeMarket] { + const [market, directionalMarket] = normalizeOutcomeMeta(sampleMeta) + if (market === undefined || directionalMarket === undefined) { + throw new Error('missing normalized sample outcome markets') + } + return [market, directionalMarket] +} + +describe('builder pattern helpers', () => { + it('creates a quote readiness contract from a USDH book', () => { + const readiness = createQuoteReadiness({ + pair: { name: '@230', label: 'USDH/USDC' }, + book: usdhBook, + maxSpreadBps: 5, + minSideDepth: 20_000, + depthLevels: 2, + }) + + expect(readiness.ready).toBe(true) + expect(readiness.pair).toBe('USDH/USDC') + expect(readiness.bestBid).toBe('0.9999') + expect(readiness.bestAsk).toBe('1.0001') + expect(readiness.spreadBps).toBeCloseTo(2, 1) + expect(readiness.depth).toEqual({ levels: 2, bid: '28k', ask: '23k' }) + }) + + it('blocks quote readiness on missing or unhealthy books', () => { + expect(createQuoteReadiness({ pair: null, book: usdhBook }).blockReason).toBe('missing_pair') + expect(createQuoteReadiness({ pair: '@230', book: null }).blockReason).toBe('empty_book') + expect( + createQuoteReadiness({ + pair: '@230', + book: { + ...usdhBook, + levels: [[{ px: '1.01', sz: '10', n: 1 }], [{ px: '1.00', sz: '10', n: 1 }]], + }, + }).blockReason, + ).toBe('crossed_book') + expect( + createQuoteReadiness({ pair: '@230', book: usdhBook, minSideDepth: 100_000 }).blockReason, + ).toBe('thin_depth') + }) + + it('creates quote summary data with estimated receive for swap surfaces', () => { + const summary = createQuoteSummaryData({ + pair: { name: '@230', base: 'USDH', quote: 'USDC', label: 'USDH/USDC' }, + book: usdhBook, + amount: '250', + payAsset: 'USDC', + maxSpreadBps: 5, + minSideDepth: 20_000, + }) + + expect(summary).toMatchObject({ + pair: 'USDH/USDC', + ready: true, + side: 'buy', + price: '1.0001', + pay: { asset: 'USDC', amount: '250' }, + receive: { asset: 'USDH', amount: '249.975002' }, + }) + + expect( + createQuoteSummaryData({ + pair: 'USDH/USDC', + book: usdhBook, + amount: '250', + payAsset: 'USDH', + receiveDecimals: 3, + }).receive, + ).toEqual({ asset: 'USDC', amount: '249.975' }) + }) + + it('keeps builder quote and ticket math exact beyond Number safe integers', () => { + const oneDollarBook = { + ...usdhBook, + levels: [ + [{ px: '1', sz: '10000000000000000', n: 1 }], + [{ px: '1', sz: '10000000000000000', n: 1 }], + ], + } satisfies L2Book + + expect( + createQuoteSummaryData({ + pair: { name: '@230', base: 'USDH', quote: 'USDC', label: 'USDH/USDC' }, + book: oneDollarBook, + amount: '9007199254740993', + payAsset: 'USDC', + receiveDecimals: 0, + }).receive, + ).toEqual({ asset: 'USDH', amount: '9007199254740993' }) + + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '9007199254740993', + price: '1', + availableQuote: '9007199254740992', + }).blockReason, + ).toBe('insufficient_balance') + }) + + it('blocks quote summary data when the amount or route is not usable', () => { + expect( + createQuoteSummaryData({ + pair: 'USDH/USDC', + book: usdhBook, + amount: null, + payAsset: 'USDC', + }).blockReason, + ).toBe('missing_amount') + expect( + createQuoteSummaryData({ + pair: '@230', + book: usdhBook, + amount: '250', + payAsset: 'USDC', + }).blockReason, + ).toBe('unsupported_route') + expect( + createQuoteSummaryData({ + pair: { name: '@230', base: 'USDH', quote: 'USDC', label: 'USDH/USDC' }, + book: usdhBook, + amount: '250', + payAsset: 'USDC', + maxSpreadBps: 1, + }).blockReason, + ).toBe('wide_spread') + }) + + it('creates a signer-ready order ticket draft without submitting writes', () => { + const readiness = createQuoteReadiness({ + pair: 'USDH/USDC', + book: usdhBook, + maxSpreadBps: 5, + minSideDepth: 20_000, + depthLevels: 2, + }) + const draft = createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1.0001', + readiness, + tif: 'Gtc', + }) + + expect(draft.canReview).toBe(true) + expect(draft.blockReason).toBeUndefined() + expect(draft.notional).toBe('25') + expect(draft.placeOrderInput).toEqual({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1.0001', + tif: 'Gtc', + }) + }) + + it('keeps the order-ticket helper as an alias for spot order drafts', () => { + expect(createOrderTicketDraft).toBe(createSpotOrderDraft) + }) + + it('blocks order ticket drafts before the wallet handoff when inputs are unsafe', () => { + expect( + createSpotOrderDraft({ + pair: null, + side: 'buy', + size: '25', + price: '1', + }).blockReason, + ).toBe('missing_pair') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '5', + price: '1', + minNotional: 10, + }).blockReason, + ).toBe('below_min_notional') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1', + readiness: createQuoteReadiness({ pair: 'USDH/USDC', book: null }), + }).blockReason, + ).toBe('quote_not_ready') + }) + + it('validates spot order draft precision, TIF, slippage, and balances', () => { + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25.123', + price: '1', + sizeDecimals: 2, + }).blockReason, + ).toBe('size_precision') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1.000123', + priceDecimals: 4, + }).blockReason, + ).toBe('price_precision') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + mode: 'market', + tif: 'Gtc', + }).blockReason, + ).toBe('invalid_tif') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'buy', + size: '25', + price: '1', + slippageBps: 30, + }).blockReason, + ).toBe('invalid_slippage') + expect( + createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'sell', + size: '25', + price: '1', + availableBase: '10', + }).blockReason, + ).toBe('insufficient_balance') + }) + + it('supports market order drafts while keeping final pricing in the order layer', () => { + const readiness = createQuoteReadiness({ pair: 'USDH/USDC', book: usdhBook }) + const draft = createSpotOrderDraft({ + pair: 'USDH/USDC', + side: 'sell', + size: '25', + mode: 'market', + readiness, + slippageBps: 30, + }) + + expect(draft.canReview).toBe(true) + expect(draft.mode).toBe('market') + expect(draft.notional).toBe('25') + expect(draft.placeOrderInput).toEqual({ + pair: 'USDH/USDC', + side: 'sell', + size: '25', + slippageBps: 30, + }) + }) + + it('creates outcome event data from normalized HIP-4 metadata and side books', () => { + const [market] = sampleMarkets() + const event = createOutcomeEventData(market, [{ book: outcomeBook }]) + + expect(event.title).toBe('BTC closes above 100k Friday') + expect(event.subtitle).toBe('Yes / No') + expect(event.sides[0]).toMatchObject({ + side: 0, + label: 'Yes', + coin: '#200', + tokenName: '+200', + probability: 71, + bestBid: '0.69', + bestAsk: '0.72', + depth: '185', + }) + expect(event.sides[1]).toMatchObject({ + side: 1, + label: 'No', + coin: '#201', + probability: null, + }) + }) + + it('creates outcome order-book rows with normalized depth percentages', () => { + const levels = createOutcomeOrderBookLevels(outcomeBook) + + expect(levels.bids[0]).toEqual({ price: '0.69', size: '100', depthPct: 100 }) + expect(levels.asks[0]).toEqual({ price: '0.72', size: '25', depthPct: 25 }) + }) + + it('creates a side book summary with health and depth context', () => { + const summary = createOutcomeOrderBookSummary(outcomeBook, { levels: 2 }) + + expect(summary).toMatchObject({ + coin: '#200', + ready: true, + bestBid: '0.69', + bestAsk: '0.72', + depth: { levels: 2, bid: '150', ask: '35', minSide: '35' }, + }) + expect(summary.spreadBps).toBeCloseTo(425.5, 1) + expect( + createOutcomeOrderBookSummary({ + ...outcomeBook, + levels: [[{ px: '0.73', sz: '10' }], [{ px: '0.72', sz: '10' }]], + }).blockReason, + ).toBe('crossed_book') + expect( + createOutcomeOrderBookSummary({ + coin: '#200', + levels: [[], []], + }).blockReason, + ).toBe('empty_book') + }) + + it('creates outcome market rows from market metadata and side reads', () => { + const markets = normalizeOutcomeMeta(sampleMeta) + const rows = createOutcomeMarketRows({ + markets, + readsByCoin: { + '#200': { probability: 70, bestBid: '0.69', bestAsk: '0.72' }, + '+201': { probability: 31, bestBid: '0.30', bestAsk: '0.33' }, + '#240': { probability: 55, bestBid: '0.54', bestAsk: '0.57' }, + '100000241': { probability: 48, bestBid: '0.47', bestAsk: '0.50' }, + }, + sortBy: 'probability', + }) + + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ + id: 20, + title: 'BTC closes above 100k Friday', + sides: [ + { coin: '#200', probability: 70 }, + { coin: '#201', probability: 31 }, + ], + }) + expect(rows[1]).toMatchObject({ + id: 24, + title: 'HYPE weekly close green', + subtitle: 'Up / Down', + sides: [ + { coin: '#240', probability: 55 }, + { coin: '#241', probability: 48 }, + ], + }) + }) + + it('creates a controlled outcome side selection contract', () => { + const [market, directionalMarket] = sampleMarkets() + const selection = createOutcomeSideSelection({ + market, + selected: '+201', + reads: [{ book: outcomeBook }, { probability: 29, bestBid: '0.28', bestAsk: '0.31' }], + }) + + expect(selection).toMatchObject({ + selectedIndex: 1, + selectedCoin: '#201', + selected: { label: 'No', coin: '#201', probability: 29 }, + event: { id: 20 }, + }) + expect( + createOutcomeSideSelection({ + market: directionalMarket, + selected: '+241', + }), + ).toMatchObject({ + selectedIndex: 1, + selectedCoin: '#241', + selected: { label: 'Down', coin: '#241' }, + event: { subtitle: 'Up / Down' }, + }) + }) + + it('resolves outcome position data by side, coin, token name, or side object', () => { + const [market] = sampleMarkets() + const noSide = market.sides[1] + if (noSide === undefined) throw new Error('missing no side') + + expect(createOutcomePositionData({ market, side: 0, quantity: '12.5' })).toMatchObject({ + market: 'BTC closes above 100k Friday', + outcome: 20, + side: 0, + sideName: 'Yes', + coin: '#200', + quantity: '12.5', + state: 'held', + }) + expect(resolveOutcomeSide(market, '#201').name).toBe('No') + expect(resolveOutcomeSide(market, '+200').name).toBe('Yes') + expect(resolveOutcomeSide(market, noSide).coin).toBe('#201') + expect(() => resolveOutcomeSide(market, '#999')).toThrow(InvalidInputError) + }) + + it('resolves held outcome side coins across a market list', () => { + const markets = normalizeOutcomeMeta(sampleMeta) + + expect(findOutcomeMarketSide(markets, '#201')).toMatchObject({ + market: { outcome: 20 }, + side: { name: 'No', tokenName: '+201' }, + }) + expect(resolveOutcomeMarketSide(markets, '+200')).toMatchObject({ + market: { name: 'BTC closes above 100k Friday' }, + side: { name: 'Yes', coin: '#200' }, + }) + expect( + createOutcomePositionDataFromSide({ + markets, + side: '#201', + quantity: '4.2', + mark: '29%', + state: 'watch', + }), + ).toMatchObject({ + market: 'BTC closes above 100k Friday', + sideName: 'No', + coin: '#201', + quantity: '4.2', + mark: '29%', + state: 'watch', + }) + expect(findOutcomeMarketSide(markets, '#999')).toBeNull() + expect(() => resolveOutcomeMarketSide(markets, '#999')).toThrow(InvalidInputError) + }) + + it('creates outcome position rows from wallet balances', () => { + const markets = normalizeOutcomeMeta(sampleMeta) + const balances = [ + { coin: '+200', token: 100_000_200, total: '12.50', hold: '2.25' }, + { coin: 'USDC', token: 0, total: '100', hold: '0' }, + { coin: '#201', token: 100_000_201, total: '1', hold: '1' }, + ] + + const firstBalance = balances[0] + if (firstBalance === undefined) throw new Error('missing test balance') + expect(findOutcomeMarketSideFromBalance(markets, firstBalance)).toMatchObject({ + side: { coin: '#200' }, + }) + expect( + createOutcomePositionRows({ + markets, + balances, + marks: { '#200': '71%', '+201': '29%' }, + }), + ).toEqual([ + { + market: 'BTC closes above 100k Friday', + outcome: 20, + side: 0, + sideName: 'Yes', + coin: '#200', + tokenName: '+200', + quantity: '10.25', + mark: '71%', + state: 'held', + }, + ]) + expect( + createOutcomePositionRows({ + markets, + balances, + includeZero: true, + }).map((row) => row.quantity), + ).toEqual(['10.25', '0']) + }) + + it('keeps outcome position rows exact and ignores unrelated balances', () => { + const markets = normalizeOutcomeMeta(sampleMeta) + + expect( + createOutcomePositionRows({ + markets, + balances: [ + { + coin: '+240', + token: 100_000_240, + total: '0.300000000000000003', + hold: '0.100000000000000001', + }, + { coin: '+999', token: 100_000_999, total: '10', hold: '0' }, + ], + marks: { '100000240': '55%' }, + }), + ).toEqual([ + { + market: 'HYPE weekly close green', + outcome: 24, + side: 0, + sideName: 'Up', + coin: '#240', + tokenName: '+240', + quantity: '0.200000000000000002', + mark: '55%', + state: 'held', + }, + ]) + }) + + it('returns null probability for invalid or crossed reads', () => { + expect(probabilityFromBook({ bestBid: '0.70', bestAsk: '0.72' })).toBe(71) + expect(probabilityFromBook({ bestBid: '0.73', bestAsk: '0.72' })).toBeNull() + expect(probabilityFromBook({ bestBid: undefined, bestAsk: '0.72' })).toBeNull() + }) +}) diff --git a/packages/sdk/test/discovery.test.ts b/packages/sdk/test/discovery.test.ts index c15f897..04f3c37 100644 --- a/packages/sdk/test/discovery.test.ts +++ b/packages/sdk/test/discovery.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { createDiscovery, findUsdhSpotPair, listUsdhSpotPairs } from '../src/discovery.js' import { NetworkError } from '../src/errors.js' import type { InfoClient } from '../src/transport/info.js' -import type { SpotMeta } from '../src/transport/types.js' +import type { L2Book, SpotMeta } from '../src/transport/types.js' const meta: SpotMeta = { universe: [ @@ -176,7 +176,9 @@ describe('createDiscovery', () => { }) it('forwards getBook to info.l2Book with nSigFigs', async () => { - const l2Book = vi.fn(async () => ({ coin: 'USDH/USDC', time: 1, levels: [[], []] })) + const l2Book = vi.fn( + async (): Promise => ({ coin: 'USDH/USDC', time: 1, levels: [[], []] }), + ) const discovery = createDiscovery(stubInfo({ l2Book })) await discovery.getBook('USDH/USDC', { nSigFigs: 5 }) expect(l2Book).toHaveBeenCalledWith('USDH/USDC', 5) diff --git a/packages/sdk/test/exchange.test.ts b/packages/sdk/test/exchange.test.ts index 8f77b6e..87d4872 100644 --- a/packages/sdk/test/exchange.test.ts +++ b/packages/sdk/test/exchange.test.ts @@ -8,6 +8,7 @@ import { createExchangeClient, isOrderResponse, } from '../src/transport/exchange.js' +import { mockCallArg } from './test-utils.js' const sig: L1Signature = { r: '0xc0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0', @@ -57,7 +58,7 @@ describe('createExchangeClient', () => { const fetch = vi.fn(async () => jsonResponse(okFilled)) const client = createExchangeClient({ network: 'mainnet', fetch }) await client.submit({ action, signature: sig, nonce: 1n }) - const [url] = fetch.mock.calls[0] ?? [] + const url = mockCallArg(fetch, 0, 0) expect(url).toBe('https://api.hyperliquid.xyz/exchange') }) @@ -65,7 +66,7 @@ describe('createExchangeClient', () => { const fetch = vi.fn(async () => jsonResponse(okFilled)) const client = createExchangeClient({ network: 'testnet', fetch }) await client.submit({ action, signature: sig, nonce: 1n }) - const [url] = fetch.mock.calls[0] ?? [] + const url = mockCallArg(fetch, 0, 0) expect(url).toBe('https://api.hyperliquid-testnet.xyz/exchange') }) @@ -73,9 +74,9 @@ describe('createExchangeClient', () => { const fetch = vi.fn(async () => jsonResponse(okFilled)) const client = createExchangeClient({ network: 'mainnet', fetch }) await client.submit({ action, signature: sig, nonce: 1735300000000n }) - const init = fetch.mock.calls[0]?.[1] - expect(init?.method).toBe('POST') - const body = JSON.parse(init?.body as string) as Record + const init = mockCallArg(fetch, 0, 1) + expect(init.method).toBe('POST') + const body = JSON.parse(init.body as string) as Record expect(body).toEqual({ action, nonce: 1735300000000, @@ -88,8 +89,8 @@ describe('createExchangeClient', () => { const client = createExchangeClient({ network: 'mainnet', fetch }) const vault = '0x000000000000000000000000000000000000abcd' await client.submit({ action, signature: sig, nonce: 1n, vaultAddress: vault }) - const init = fetch.mock.calls[0]?.[1] - const body = JSON.parse(init?.body as string) as { vaultAddress?: string } + const init = mockCallArg(fetch, 0, 1) + const body = JSON.parse(init.body as string) as { vaultAddress?: string } expect(body.vaultAddress).toBe(vault) }) @@ -97,8 +98,8 @@ describe('createExchangeClient', () => { const fetch = vi.fn(async () => jsonResponse(okFilled)) const client = createExchangeClient({ network: 'mainnet', fetch }) await client.submit({ action, signature: sig, nonce: 1n, expiresAfter: 31_000n }) - const init = fetch.mock.calls[0]?.[1] - const body = JSON.parse(init?.body as string) as { expiresAfter?: number } + const init = mockCallArg(fetch, 0, 1) + const body = JSON.parse(init.body as string) as { expiresAfter?: number } expect(body.expiresAfter).toBe(31_000) }) @@ -106,8 +107,8 @@ describe('createExchangeClient', () => { const fetch = vi.fn(async () => jsonResponse(okFilled)) const client = createExchangeClient({ network: 'mainnet', fetch }) await client.submit({ action, signature: sig, nonce: 1n }) - const init = fetch.mock.calls[0]?.[1] - const body = JSON.parse(init?.body as string) as Record + const init = mockCallArg(fetch, 0, 1) + const body = JSON.parse(init.body as string) as Record expect('vaultAddress' in body).toBe(false) }) }) diff --git a/packages/sdk/test/info.test.ts b/packages/sdk/test/info.test.ts index 3ae2c19..ed0606c 100644 --- a/packages/sdk/test/info.test.ts +++ b/packages/sdk/test/info.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { InvalidInputError, NetworkError } from '../src/errors.js' import { createInfoClient } from '../src/transport/info.js' import type { L2Book, OutcomeMeta, SpotMeta } from '../src/transport/types.js' +import { mockCallArg } from './test-utils.js' function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { @@ -82,17 +83,18 @@ describe('createInfoClient', () => { const client = createInfoClient({ network: 'mainnet', fetch }) await client.spotMeta() expect(fetch).toHaveBeenCalledOnce() - const [url, init] = fetch.mock.calls[0] ?? [] + const url = mockCallArg(fetch, 0, 0) + const init = mockCallArg(fetch, 0, 1) expect(url).toBe('https://api.hyperliquid.xyz/info') - expect(init?.method).toBe('POST') - expect(JSON.parse(init?.body as string)).toEqual({ type: 'spotMeta' }) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual({ type: 'spotMeta' }) }) it('targets the testnet endpoint', async () => { const fetch = vi.fn(async () => jsonResponse(sampleSpotMeta)) const client = createInfoClient({ network: 'testnet', fetch }) await client.spotMeta() - const [url] = fetch.mock.calls[0] ?? [] + const url = mockCallArg(fetch, 0, 0) expect(url).toBe('https://api.hyperliquid-testnet.xyz/info') }) }) @@ -111,8 +113,8 @@ describe('outcomeMeta', () => { const fetch = vi.fn(async () => jsonResponse(sampleOutcomeMeta)) const client = createInfoClient({ network: 'mainnet', fetch }) const result = await client.outcomeMeta() - const [, init] = fetch.mock.calls[0] ?? [] - expect(JSON.parse(init?.body as string)).toEqual({ type: 'outcomeMeta' }) + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'outcomeMeta' }) expect(result).toEqual(sampleOutcomeMeta) }) @@ -149,8 +151,8 @@ describe('l2Book', () => { const fetch = vi.fn(async () => jsonResponse(sampleL2Book)) const client = createInfoClient({ network: 'mainnet', fetch }) await client.l2Book('@230', 5) - const [, init] = fetch.mock.calls[0] ?? [] - expect(JSON.parse(init?.body as string)).toEqual({ + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'l2Book', coin: '@230', nSigFigs: 5, @@ -161,8 +163,8 @@ describe('l2Book', () => { const fetch = vi.fn(async () => jsonResponse(sampleL2Book)) const client = createInfoClient({ network: 'mainnet', fetch }) await client.l2Book('@230') - const [, init] = fetch.mock.calls[0] ?? [] - expect(JSON.parse(init?.body as string)).toEqual({ + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'l2Book', coin: '@230', nSigFigs: null, @@ -197,8 +199,8 @@ describe('allMids', () => { const fetch = vi.fn(async () => jsonResponse({ BTC: '60000', '@0': '1.0001' })) const client = createInfoClient({ network: 'mainnet', fetch }) const result = await client.allMids() - const [, init] = fetch.mock.calls[0] ?? [] - expect(JSON.parse(init?.body as string)).toEqual({ type: 'allMids' }) + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'allMids' }) expect(result).toEqual({ BTC: '60000', '@0': '1.0001' }) }) @@ -230,8 +232,8 @@ describe('frontendOpenOrders', () => { 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({ + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'frontendOpenOrders', user: '0x000000000000000000000000000000000000abcd', }) @@ -257,8 +259,8 @@ describe('orderStatus', () => { 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({ + const init = mockCallArg(fetch, 0, 1) + expect(JSON.parse(init.body as string)).toEqual({ type: 'orderStatus', user: '0x000000000000000000000000000000000000abcd', oid: 91490942, diff --git a/packages/sdk/test/kit.test.ts b/packages/sdk/test/kit.test.ts index 75a60ed..9de58e9 100644 --- a/packages/sdk/test/kit.test.ts +++ b/packages/sdk/test/kit.test.ts @@ -83,7 +83,7 @@ function jsonResponse(body: unknown): Response { } function backend(exchangeResponse: unknown): { - fetch: typeof fetch + fetch: typeof globalThis.fetch getExchangeBody: () => Record | undefined } { let exchangeBody: Record | undefined @@ -100,12 +100,12 @@ function backend(exchangeResponse: unknown): { return jsonResponse(exchangeResponse) } throw new Error(`unexpected url: ${url}`) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch return { fetch, getExchangeBody: () => exchangeBody } } function reverseSwapBackend(exchangeResponse: unknown): { - fetch: typeof fetch + fetch: typeof globalThis.fetch getExchangeBody: () => Record | undefined } { let exchangeBody: Record | undefined @@ -122,12 +122,12 @@ function reverseSwapBackend(exchangeResponse: unknown): { return jsonResponse(exchangeResponse) } throw new Error(`unexpected url: ${url}`) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch return { fetch, getExchangeBody: () => exchangeBody } } function outcomeBackend(): { - fetch: typeof fetch + fetch: typeof globalThis.fetch getInfoBodies: () => Record[] } { const infoBodies: Record[] = [] @@ -148,7 +148,7 @@ function outcomeBackend(): { return jsonResponse({ '#200': '0.733315', '#201': '0.266685', BTC: '80657' }) } throw new Error(`unexpected /info body: ${JSON.stringify(body)}`) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch return { fetch, getInfoBodies: () => infoBodies } } @@ -158,7 +158,7 @@ function routingBackend( exchangeResponse: unknown, hcBalances: HcBalanceFixture[] = ['0'], ): { - fetch: typeof fetch + fetch: typeof globalThis.fetch getExchangeBody: () => Record | undefined getInfoBodies: () => Record[] } { @@ -188,7 +188,7 @@ function routingBackend( return jsonResponse(exchangeResponse) } throw new Error(`unexpected url: ${url}`) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch return { fetch, getExchangeBody: () => exchangeBody, getInfoBodies: () => infoBodies } } @@ -280,7 +280,7 @@ describe('swap', () => { }) it('rejects full sell slippage before fetching or signing', async () => { - const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof fetch + const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof globalThis.fetch const signTypedData = vi.fn(stubSigner.signTypedData) const kit = createUsdhKit({ network: 'mainnet', @@ -422,7 +422,7 @@ describe('swap', () => { } if (typeof body.nonce === 'number') nonces.push(body.nonce) return jsonResponse(filledResponse) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) await Promise.all([ @@ -517,7 +517,7 @@ describe('getQuote', () => { }) it('validates input before contacting the network', async () => { - const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof fetch + const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof globalThis.fetch const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) await expect(kit.getQuote({ from: 'USDC', amount: 0n })).rejects.toThrow(InvalidInputError) expect(fetch).not.toHaveBeenCalled() @@ -552,7 +552,7 @@ describe('getRoute', () => { }) } throw new Error(`unexpected /info body: ${JSON.stringify(body)}`) - }) as unknown as typeof fetch + }) as unknown as typeof globalThis.fetch const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) const route = await kit.getRoute({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) @@ -576,7 +576,7 @@ describe('getRoute', () => { }) it('rejects full sell slippage during route preflight', async () => { - const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof fetch + const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof globalThis.fetch const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) await expect( diff --git a/packages/sdk/test/orders.test.ts b/packages/sdk/test/orders.test.ts index 12c9b78..d859aa1 100644 --- a/packages/sdk/test/orders.test.ts +++ b/packages/sdk/test/orders.test.ts @@ -2,7 +2,7 @@ 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 { ExchangeClient, ExchangeResponse } 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' @@ -80,11 +80,13 @@ function nonceFactory() { } function exchangeOk(response: unknown): ExchangeClient { - return { submit: vi.fn(async () => ({ status: 'ok', response })) } + return { submit: vi.fn(async (): Promise => ({ status: 'ok', response })) } } function exchangeErr(message: string): ExchangeClient { - return { submit: vi.fn(async () => ({ status: 'err', response: message })) } + return { + submit: vi.fn(async (): Promise => ({ status: 'err', response: message })), + } } function openOrder(coin: string, oid: number): OpenOrder { diff --git a/packages/sdk/test/signing.test.ts b/packages/sdk/test/signing.test.ts index d1df563..264d131 100644 --- a/packages/sdk/test/signing.test.ts +++ b/packages/sdk/test/signing.test.ts @@ -5,7 +5,8 @@ import { bigintToBytesBE, bytesToHex, concatBytes } from '../src/bytes.js' import { SigningError } from '../src/errors.js' import { encode as msgpackEncode } from '../src/msgpack.js' import { computeActionHash, parseSignature, signL1Action } from '../src/signing.js' -import type { Signer } from '../src/types/signer.js' +import type { SignTypedDataArgs, Signer } from '../src/types/signer.js' +import { mockCallArg } from './test-utils.js' const stubSigner: Signer = { address: '0x0000000000000000000000000000000000000001', @@ -138,16 +139,16 @@ describe('signL1Action', () => { network: 'mainnet', }) expect(signTypedData).toHaveBeenCalledOnce() - const args = signTypedData.mock.calls[0]?.[0] - expect(args?.domain).toEqual({ + const args = mockCallArg(signTypedData, 0, 0) + expect(args.domain).toEqual({ name: 'Exchange', version: '1', chainId: 1337, verifyingContract: '0x0000000000000000000000000000000000000000', }) - expect(args?.primaryType).toBe('Agent') - expect(args?.message.source).toBe('a') - expect(args?.message.connectionId).toBe(computeActionHash(orderAction, 1n)) + expect(args.primaryType).toBe('Agent') + expect(args.message.source).toBe('a') + expect(args.message.connectionId).toBe(computeActionHash(orderAction, 1n)) }) it('uses source "b" on testnet', async () => { @@ -161,8 +162,8 @@ describe('signL1Action', () => { nonce: 1n, network: 'testnet', }) - const args = signTypedData.mock.calls[0]?.[0] - expect(args?.message.source).toBe('b') + const args = mockCallArg(signTypedData, 0, 0) + expect(args.message.source).toBe('b') }) it('commits expiresAfter into the signed connectionId', async () => { @@ -177,8 +178,8 @@ describe('signL1Action', () => { expiresAfter: 31_000n, network: 'testnet', }) - const args = signTypedData.mock.calls[0]?.[0] - expect(args?.message.connectionId).toBe(computeActionHash(orderAction, 1n, undefined, 31_000n)) + const args = mockCallArg(signTypedData, 0, 0) + expect(args.message.connectionId).toBe(computeActionHash(orderAction, 1n, undefined, 31_000n)) }) it('returns r/s/v parsed from the signer output', async () => { diff --git a/packages/sdk/test/test-utils.ts b/packages/sdk/test/test-utils.ts new file mode 100644 index 0000000..d23ebde --- /dev/null +++ b/packages/sdk/test/test-utils.ts @@ -0,0 +1,11 @@ +import { expect } from 'vitest' + +export function mockCallArg( + mock: { mock: { calls: readonly unknown[][] } }, + callIndex: number, + argIndex: number, +): T { + const call = mock.mock.calls[callIndex] + expect(call).toBeDefined() + return call?.[argIndex] as T +} diff --git a/packages/sdk/tsconfig.test.json b/packages/sdk/tsconfig.test.json new file mode 100644 index 0000000..38d9951 --- /dev/null +++ b/packages/sdk/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noPropertyAccessFromIndexSignature": false, + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/scripts/consumer-smoke.mjs b/scripts/consumer-smoke.mjs index bf8c0db..adf9571 100644 --- a/scripts/consumer-smoke.mjs +++ b/scripts/consumer-smoke.mjs @@ -260,10 +260,9 @@ function symlinkType() { function esmConsumerSource() { return `import { createRequire } from 'node:module' import { - createUsdhKit, - listUsdhSpotPairs, + createOutcomeEventData, + createQuoteSummaryData, normalizeOutcomeMeta, - outcomeCoin, } from '@usdh-kit/sdk' import { USDHSwap, friendlyError } from '@usdh-kit/widget' @@ -271,16 +270,25 @@ const require = createRequire(import.meta.url) const cssPath = require.resolve('@usdh-kit/widget/styles.css') const tailwindContent = require('@usdh-kit/widget/tailwind-content') -if (typeof createUsdhKit !== 'function') throw new Error('SDK ESM kit export failed') - -const pairs = listUsdhSpotPairs({ - tokens: [ - { name: 'USDC', szDecimals: 6, weiDecimals: 8, index: 0, tokenId: '0x0', isCanonical: true, evmContract: null, fullName: null }, - { name: 'USDH', szDecimals: 6, weiDecimals: 8, index: 150, tokenId: '0x1', isCanonical: false, evmContract: null, fullName: null }, +const pair = { + kind: 'spot', + name: '@230', + base: 'USDH', + quote: 'USDC', + usdhRole: 'base', + index: 230, + tokens: [150, 0], +} +const book = { + coin: '@230', + time: 1778427457824, + levels: [ + [{ px: '0.9999', sz: '17000', n: 1 }], + [{ px: '1.0001', sz: '14000', n: 1 }], ], - universe: [{ name: '@230', tokens: [150, 0], index: 230, isCanonical: false }], -}) -if (pairs[0]?.base !== 'USDH') throw new Error('SDK ESM discovery export failed') +} +const quote = createQuoteSummaryData({ pair, book, amount: '250', payAsset: 'USDC' }) +if (quote.receive?.asset !== 'USDH') throw new Error('SDK ESM quote helper failed') const [market] = normalizeOutcomeMeta({ outcomes: [ @@ -293,7 +301,8 @@ const [market] = normalizeOutcomeMeta({ ], }) if (!market) throw new Error('SDK ESM outcome metadata failed') -if (outcomeCoin(market.outcome, 0) !== '#200') throw new Error('SDK ESM outcome helper failed') +const event = createOutcomeEventData(market) +if (event.sides[0].coin !== '#200') throw new Error('SDK ESM outcome helper failed') if (typeof USDHSwap !== 'function') throw new Error('Widget ESM export failed') if (friendlyError(new Error('boom')) !== 'boom') throw new Error('Widget helper export failed') if (!cssPath.replaceAll('\\\\', '/').endsWith('/dist/styles.css')) { @@ -311,6 +320,7 @@ const widgetCss = require.resolve('@usdh-kit/widget/styles.css') const widgetContent = require('@usdh-kit/widget/tailwind-content') if (typeof sdk.createUsdhKit !== 'function') throw new Error('SDK CJS export failed') +if (typeof sdk.createQuoteReadiness !== 'function') throw new Error('SDK CJS helper export failed') try { require('@usdh-kit/widget') throw new Error('Widget root unexpectedly allowed CommonJS require') @@ -333,10 +343,10 @@ if (!Array.isArray(widgetContent) || widgetContent.length === 0) { function tsConsumerSource() { return `import { - createUsdhKit, - normalizeOutcomeMeta, - outcomeCoin, + createOutcomeOrderBookSummary, + createQuoteSummaryData, type L2Book, + type QuoteSummaryData, } from '@usdh-kit/sdk' import { USDHSwap, type USDHSwapProps, type WidgetTheme } from '@usdh-kit/widget' import widgetContent = require('@usdh-kit/widget/tailwind-content') @@ -350,26 +360,20 @@ const book: L2Book = { ], } -const kitFactory: typeof createUsdhKit = createUsdhKit -const [market] = normalizeOutcomeMeta({ - outcomes: [ - { - outcome: 20, - name: 'USDH weekly volume clears $5m', - description: 'class:volume|asset:USDH|target:5000000', - sideSpecs: [{ name: 'Yes' }, { name: 'No' }], - }, - ], +const quote: QuoteSummaryData = createQuoteSummaryData({ + pair: { name: '@230', base: 'USDH', quote: 'USDC' }, + book, + amount: '250', + payAsset: 'USDC', }) -const coin = market ? outcomeCoin(market.outcome, 0) : '#0' +const summary = createOutcomeOrderBookSummary({ ...book, coin: '#200' }) const theme: WidgetTheme = 'dark' const props: USDHSwapProps = { network: 'mainnet', theme } const Widget = USDHSwap const content: string[] = widgetContent -void book -void kitFactory -void coin +void quote +void summary void props void Widget void content