npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ostium/builder-sdk

v0.12.0

Published

Ostium builder SDK

Readme

Ostium Builder SDK

Ostium Builder Service Banner

TypeScript SDK for trading Stocks, Commodities, Forex, Indices, Crypto and ETF perps on Ostium.

npm install @ostium/builder-sdk viem
# or
bun add @ostium/builder-sdk viem

Guides:

  • BUILDER.md — Integrating Ostium into your app with builder fees
  • TRADER.md — Building trading strategies programmatically on Ostium

Examples

Runnable programs live in examples/ — an order ticket, a live positions view, and a complete trade loop. Two of the three need no credentials.

bun examples/order-ticket.ts

Building with an AI coding agent? llms.txt is a flat, prescriptive reference written for exactly that, and ships with the package.

Project Structure

src/
  client.ts          # OstiumClient — public API
  config.ts          # Named param interfaces for each mode
  types.ts           # OpenTradeParams, CloseTradeParams, etc.
  errors.ts          # OstiumError + OstiumErrorCode
  encoder.ts         # ABI encoding helpers (openTrade, closeTrade, ...)
  cli.ts             # Interactive CLI (bunx ostium)
  signer/            # SignerStrategy — Self vs Delegated wrapping
  submitter/         # SubmissionStrategy — EOA vs Pimlico UserOp
  internal/
    contracts.ts     # On-chain addresses (mainnet + testnet)
    decimal.ts       # parseUsdc, parsePrice, parseLeverage
    erc20.ts         # Minimal ERC-20 ABI
    contract.ts      # Ostium Trading ABI
    open-builder.ts  # Trade struct construction
    precision.ts     # Precision constants
    validation.ts    # Input validation helpers
  data/          # Read-only subgraph + price feed client
    client.ts        # OstiumSubgraphClient
    types.ts         # Pair, Position, Fill, OpenOrder, Order, …
    queries.ts       # GraphQL queries
    internal/        # Formatters, calculations, pagination
dist/                # Compiled output (bun run build)

Development

bun install          # install dependencies
bun run build        # compile to dist/
bun run typecheck    # tsc --noEmit

Modes

The four client modes differ in who signs and how the transaction is submitted.

Signing

| Mode | Who owns USDC + positions | Who signs transactions | |:--|:--|:--| | Self | Your EOA | Your EOA | | Delegated | Trader address (separate EOA) | Delegate EOA, wrapped in delegatedAction(traderAddress, data) |

Submission

| Mode | How the tx is sent | Gas | |:--|:--|:--| | Self (EOA) | Standard eth_sendRawTransaction | ETH required per tx | | Gasless | Pimlico ERC-4337 UserOperation via Safe smart account | ETH-free after one-time setup |

The Four Combinations

| Constructor | Signer | Submitter | Notes | |:--|:--|:--|:--| | createSelfAndSelf | EOA | EOA | Simplest — one key, pays gas | | createSelfAndGasless | EOA (owner) + Safe (delegate) | Pimlico UserOp | One-time setup, then free | | createDelegatedAndSelf | Delegate EOA | Delegate EOA | Delegate holds ETH, trader holds USDC | | createDelegatedAndGasless | Delegate EOA (owner) + Safe (delegate) | Pimlico UserOp | No ETH after trader calls setDelegate(safeAddress) |

import { OstiumClient, OrderType } from '@ostium/builder-sdk';

// Self + Self
const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
});

// Self + Gasless
const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});

// Delegated + Self
const client = await OstiumClient.createDelegatedAndSelf({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
});

// Delegated + Gasless
const client = await OstiumClient.createDelegatedAndGasless({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});

Construction is network-free, with one exception: the gasless factories derive the smart-account address with a single eth_call the first time. Pass the known address back as safeAddress (from client.getSmartAccountAddress()) to skip that call and make gasless construction instant too:

const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  safeAddress: '0xKnownSmartAccountAddress', // optional — deterministic per key + chain
});

Common Options

All OstiumClient.create* factories and OstiumClient.createReadOnly() accept shared read/stream options:

const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://arb-mainnet.g.alchemy.com/v2/...',
  rpcWsUrl: 'wss://your-arbitrum-node/ws',  // streamAccountUpdates()
  subgraphUrl: 'https://...',            // optional subgraph override
  builderApiUrl: 'https://builder.prod.bedrock.ostium.io',
  testnet: false,
});

rpcWsUrl is only required when starting streamAccountUpdates(), which subscribes to Arbitrum contract logs. Everything it reads is standard JSON-RPC, so any node, Infura, QuickNode or a proxy of your own works — and no vendor key has to reach a browser bundle.

alchemyApiKey remains supported and builds the Alchemy URLs for you. Either option can also be passed per stream call to override the constructor value.

Build Unsigned Transactions

Every write method now has a get*Tx() counterpart that returns transaction data without signing or submitting it.

const client = await OstiumClient.createSelfAndSelf({
  traderAddress: '0xTraderAddress',
});

const tx = client.getOpenTradeTx({
  pairId: 0,
  buy: true,
  price: '65000',
  collateral: '100',
  leverage: '5',
  type: OrderType.Market,
});

if (tx.kind === 'eoa') {
  await window.ethereum.request({
    method: 'eth_sendTransaction',
    params: [{
      from: tx.from,
      to: tx.to,
      data: tx.data,
      value: `0x${tx.value.toString(16)}`,
    }],
  });
}

Gasless build-only clients return a Safe-style request instead:

const gaslessClient = await OstiumClient.createSelfAndGasless({
  traderAddress: '0xTraderAddress',
  safeAddress: '0xSafeAddress',
});

const safeTx = gaslessClient.getOpenTradeTx({ ... });
// safeTx.kind === 'safe'
// safeTx.safeAddress identifies the Safe that should execute safeTx.calls[0]

The existing write methods (openTrade, closeTrade, approveUsdc, etc.) still work the same way, but they now submit the transaction built by the corresponding get*Tx() method.

Read-only (no private key)

Use createReadOnly to access market data without a signer. All read methods are available; write methods throw INVALID_CONFIG.

const reader = await OstiumClient.createReadOnly();
const { pairs } = await reader.getPairs();
const positions = await reader.getOpenPositions({ user: '0xTrader...' });
const balances = await reader.getBalances('0xTrader...');

Stream Position Updates

If you already have an OpenPositionsResponse, you can stream live price-driven updates for those positions without re-fetching the full position set.

const positions = await client.getOpenPositions();
const stream = client.streamPositionUpdates(positions);

stream.onUpdate(next => {
  console.log(next.marginSummary.totalRawPnlUsd);
  console.log(next.pairPositions[0]?.position.unrealizedPnl);
});

stream.onError(err => console.error(err));
stream.close();

The SDK subscribes once per unique pairId in the response, then updates the full OpenPositionsResponse as prices change.

Common Options

All constructors (including createReadOnly) accept these optional overrides:

{
  testnet?:       boolean;  // Arbitrum Sepolia when true (default false)
  subgraphUrl?:   string;   // override the default subgraph endpoint
  builderApiUrl?: string;   // override the builder API base URL (prices, OHLC, WebSocket)
}

Example:

const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: '...',
  subgraphUrl:   'https://your-subgraph.example.com/graphql',
  builderApiUrl: 'https://your-builder-api.example.com',
});

const reader = await OstiumClient.createReadOnly({
  subgraphUrl:   'https://your-subgraph.example.com/graphql',
  builderApiUrl: 'https://your-builder-api.example.com',
});

Self + Gasless: One-Time Setup

A Safe smart account is derived deterministically from your private key. You need to register it as your on-chain delegate once:

const client = await OstiumClient.createSelfAndGasless({
  traderPrivateKey: '0x...',
  pimlicoUrl: 'https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161',
});

console.log('EOA (trader):', client.getTraderAddress());
console.log('Safe (delegate):', client.getSmartAccountAddress());

await client.approveUsdc('max');         // EOA → TradingStorage approval (gas, once)
await client.setupGaslessDelegation();   // EOA → setDelegate(Safe) (gas, once)

// All trades from here are gasless UserOps
await client.openTrade({ ... });

Delegated Modes: Setup Checklist

The trader account must perform two one-time operations before the delegate can trade:

// 1. Trader approves USDC
const traderClient = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0xTraderKey',
  rpcUrl: '...',
});
await traderClient.approveUsdc('max');

// 2a. Delegated + Self: trader registers the delegate EOA
await traderClient.setDelegate('0xDelegateEOA');

// 2b. Delegated + Gasless: trader registers the delegate's Safe
const delegateClient = await OstiumClient.createDelegatedAndGasless({
  delegatePrivateKey: '0xDelegateKey',
  traderAddress: '0xTraderAddress',
  pimlicoUrl: '...',
});
await traderClient.setDelegate(delegateClient.getSmartAccountAddress()!);

CLI — Environment Variable Configuration

The ostium CLI can load all credentials from a .env file (via dotenv). Create a .env file in your working directory with the variables relevant to your mode:

# ── Universal ─────────────────────────────────────────────────────────────────

# Self / main EOA private key (required for Self modes; also used as the main key in Test + Delegated)
PRIVATE_KEY=0x...

# Arbitrum RPC URL — required for Self+Self and Delegated+Self; optional for gasless modes
ARB_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY

# ── Delegated modes ───────────────────────────────────────────────────────────

# Delegate EOA private key (falls back to PRIVATE_KEY if not set)
DELEGATE_PRIVATE_KEY=0x...

# Trader address the delegate acts on behalf of (required for Trade + Delegated)
TRADER_ADDRESS=0x...

# ── Gasless modes ─────────────────────────────────────────────────────────────

# Pimlico sponsor URL (required for Self+Gasless and Delegated+Gasless)
PIMLICO_URL=https://builder.prod.bedrock.ostium.io/v1/sponsor?chainId=42161

When the CLI detects a .env file it skips the interactive key/URL prompts and loads all values automatically. Fields not present in the env file are prompted interactively as normal.


Networks

| Network | Chain ID | testnet | |:--|:-:|:-:| | Arbitrum One | 42161 | false (default) | | Arbitrum Sepolia | 421614 | true |

const client = await OstiumClient.createSelfAndSelf({
  traderPrivateKey: '0x...',
  rpcUrl: 'https://sepolia-rollup.arbitrum.io/rpc',
  testnet: true,
});

API Reference

Enums

import { OrderType, CancelOrderType } from '@ostium/builder-sdk';

OrderType.Market  // 'market'
OrderType.Limit   // 'limit'
OrderType.Stop    // 'stop'

CancelOrderType.Limit        // 'limit'
CancelOrderType.PendingOpen  // 'pendingOpen'
CancelOrderType.PendingClose // 'pendingClose'

Trading Methods

All return Promise<SubmissionResult> as soon as the transaction is submitted (UserOp or EOA). The hash is available immediately; the SDK does not wait for receipt or parse oracle events.

interface SubmissionResult {
  txHash: `0x${string}`;
  smartAccountAddress?: `0x${string}`; // gasless modes only
}

Use getOrders({ initiatedTxHashes: [result.txHash] }) to poll by submission hash, or orderIds / recent orders for the trader.

Submissions that target the Trading contract also report their transaction hash to the builder API for SDK usage attribution. This is a fire-and-forget background request — it is never awaited and never affects trading calls.

openTrade(params)

const result = await client.openTrade({
  pairId:      0,                  // trading pair (use getPairs() to list all)
  buy:         true,               // true = long, false = short
  price:       '65000',            // entry price
  collateral:  '100',              // USD; fixed minimum is MIN_OPEN_SIZE_USD ($5)
  leverage:    '10',               // multiplier; contract-side limits apply
  type:        OrderType.Market,
  takeProfit?: '70000',
  stopLoss?:   '60000',
  slippage?:   50,                 // bps, market orders only
  isDayTrade?: false,              // true when leverage > pair.overnightMaxLeverage
});
console.log(result.txHash);

// Override fee only (address from client config):
await client.openTrade({ /* ... */, builder: { feeBps: 10 } });

// Override address only (fee from client config):
await client.openTrade({ /* ... */, builder: { address: '0x...' } });

Day trades are auto-closed before market close. Set isDayTrade: true when the desired leverage exceeds pair.overnightMaxLeverage (and overnightMaxLeverage > 0). Use getPairs() to read both thresholds per pair.

closeTrade(params)

pairId and idx come from getOpenPositions():

const { pairPositions } = await client.getOpenPositions();
const { pairId, idx } = pairPositions[0].position;

await client.closeTrade({
  pairId,           // from Position.pairId
  idx,              // from Position.idx
  price: '66000',
  closePercent: 100,  // 1–100, partial closes supported
  slippage?: 50,
});

cancelOrder(params)

TypeScript narrows the required fields by type:

// Cancel a pending limit order
const [order] = await client.getOpenOrders();
await client.cancelOrder({ type: CancelOrderType.Limit, pairId: order.pairId, idx: order.idx });

// Cancel a timed-out market open (orderId from chain / indexer)
await client.cancelOrder({ type: CancelOrderType.PendingOpen, orderId: 123 });

// Cancel a timed-out market close — retry: true re-submits the close
await client.cancelOrder({ type: CancelOrderType.PendingClose, orderId: 123, retry: true });

modifyOrder(params)

pairId and idx come from getOpenPositions() (for an open position's TP/SL) or getOpenOrders() / the account stream's limits (for a pending limit order):

// Update TP on an open position
await client.modifyOrder({ pairId, idx, takeProfit: '72000' });

// Update SL on an open position
await client.modifyOrder({ pairId, idx, stopLoss: '61000' });

// Reprice a limit order (with optional new TP/SL)
await client.modifyOrder({ pairId, idx, price: '64000', takeProfit: '70000' });

// Change only a limit order's TP/SL: pass its current price so it stays put
await client.modifyOrder({
  target: 'limit', pairId: order.pairId, idx: order.idx,
  price: order.limitPx, takeProfit: '70000', stopLoss: '60000',
});

// Reprice a limit order and keep its TP/SL: pass them back too, or they are cleared
await client.modifyOrder({
  target: 'limit', pairId: order.pairId, idx: order.idx,
  price: '64000', takeProfit: order.tpPx, stopLoss: order.slPx,
});

A position and a pending limit order on the same pair can both be at idx 0: they number their slots separately, and the contract tells them apart by which function is called. A takeProfit or stopLoss without price always edits the open position at that pairId / idx, never the limit order, even when the idx came from getOpenOrders(). A pending limit order's TP and SL can only be changed together with its price, so pass its current limitPx as price.

Set target to say which one you mean and have a mismatch rejected instead of editing the wrong one: 'limit' requires price and at least one of takeProfit / stopLoss, and 'position' rejects price. Without target, a price selects the limit order and no price selects the position, which is how earlier versions behaved: a caller who does not pass target is not protected from the collision above.

On a limit order, takeProfit and stopLoss are both written, so one you leave out is cleared, and a price on its own would remove both. To keep them, pass the order's current tpPx / slPx; to clear one on purpose, pass '0'. You cannot update both on an open position in one call; send two calls.

limitPx, tpPx and slPx are returned as JS numbers. Resending them is exact for normal prices, but a value with more than about 15 significant figures comes back with the extra digits dropped, and a price below 1e-6 is formatted as "1e-7", which modifyOrder rejects. For those, write the price out as a plain decimal string.

updateCollateral(params)

pairId and idx come from getOpenPositions():

await client.updateCollateral({ pairId, idx, amount: '50' });   // add $50
await client.updateCollateral({ pairId, idx, amount: '-30' });  // remove $30

USDC + Balance Helpers

const { current, required, sufficient } = await client.checkUsdcAllowance('100');
await client.approveUsdc('1000');   // approve $1000
await client.approveUsdc('max');    // approve MaxUint256

// All three values are decimal strings produced by formatUnits (e.g. "1234.56")
// Parse with Number() / parseFloat() only when you need numeric arithmetic.
const { usdc, eth, allowance } = await client.getBalances();
// In read-only mode, pass the address explicitly:
const { usdc, eth, allowance } = await client.getBalances('0xTrader...');

Onboarding

getOnboardingStatus(params?)

What still stands between a trader and their first trade, with the transaction that clears each step. Replaces hand-rolling the approve / delegate / gasless-setup sequence.

const status = await client.getOnboardingStatus({ requiredUsd: '1000' });

status.ready;              // true when nothing is outstanding
status.needsApproval;      // USDC allowance below requiredUsd
status.needsDelegate;      // no delegate registered, or the wrong one
status.needsGaslessSetup;  // Self + Gasless only
status.currentDelegate;    // whatever is registered on-chain today
status.allowance;          // and usdcBalance / ethBalance

for (const step of status.steps) {
  console.log(step.description);        // render a checklist
  await wallet.sendTransaction(step.tx);
}

Every step must be sent from the trader's own account — a delegate can neither approve USDC nor register itself.

Read Methods

All read methods are available on both OstiumClient and OstiumSubgraphClient. On OstiumClient the connected trader address is used by default wherever user is optional.

getPairs(params?)

All trading pairs with live prices, market-status flags, computed size limits, and rollover rates.

const { pairs } = await client.getPairs();
// Optional — filter to specific pairs:
const { pairs } = await client.getPairs({ pairIds: [0, 1, 4] });

// Pair fields:
// pairId, pairTo, pairFrom, category
// maxLeverage, overnightMaxLeverage
// openFee, closeFee               — bps; openFee includes configured builder fee
// oracleFee                       — flat USDC per order; no live price needed
// minSz, maxBSz, maxSSz, minNtl
// openInterest, buyOpenInterest, sellOpenInterest, maxOpenInterest
// rolloverRate: { long, short }   — 8hr % by side
// rolloverFeePerBlock
// midPx, askPx, bidPx
// isMarketOpen, isDayTradingClosed, secondsToToggleIsDayTradingClosed
// schedule                        — market hours (see below)

Pair symbol names from the subgraph are normalized automatically (e.g. CL → WTI, FTSE → UK100, SPX → US500).

schedule describes the pair's market hours as reported by the live price feed (omitted when the feed is unavailable):

// 24/7 markets (crypto):
// schedule → { id: 1, alwaysOpen: true }

// Scheduled markets:
// schedule → {
//   id: 3,
//   timezone: 'America/New_York',
//   openingHours: ['Mo-Th 00:00-16:59,18:00-24:00', 'Fr 00:00-16:59', 'Su 18:00-24:00'],
// }

getAllPrices()

Live mid/bid/ask prices for every pair, keyed by pairId.

const { prices } = await client.getAllPrices();
// prices['0'] → { mid: '65000', bid: '64990', ask: '65010' }

getOpenPositions(params?)

Open positions, margin summary, and per-position live PnL. Block number is fetched automatically on OstiumClient.

const { pairPositions, marginSummary, time } =
  await client.getOpenPositions();

// Each position:
const { pairId, pid, idx, side, szi, entryPx, leverage, ntl, trader,
        unrealizedPnl, returnOnEquity, liquidationPx,
        collateralUsed, cumRollover, tpPx, slPx,
        openTimestamp, isDayTrade } = pairPositions[0].position;

// marginSummary: accountValue, totalCollateralUsed, totalNtlPos, totalRawPnlUsd
// marginSummary.totalWithdrawable: max collateral removable across all positions
// time: server time (Unix ms)

getFills(params?)

Executed fills, newest first.

const fills = await client.getFills();
// Filter options:
const fills = await client.getFills({ pairId: 0, limit: 50 });
// All traders (no address filter):
const fills = await client.getFills({ user: 'ALL' });

// fill.fees: { opening, rollover, liquidation, builder, priceImpact, oracle }
// fees.oracle is what this order was charged, as indexed — record it rather than assuming a flat fee.
// fill.limitId: the limit slot on the Open fill of a triggered limit/stop; omitted otherwise.

getFillsByTime(params)

Same as getFills but with a time range filter (Unix milliseconds).

const fills = await client.getFillsByTime({
  startTime: Date.now() - 7 * 86_400_000,  // last 7 days
  endTime:   Date.now(),                    // optional, defaults to now
  pairId:    0,                             // optional pair filter
});

getOpenOrders(params?)

Active limit orders for a trader.

const orders = await client.getOpenOrders();
// Each order: pairId, pairTo, pairFrom, idx, side, limitPx, szi,
// ntl, collateralUsed, leverage, orderType, tpPx?, slPx?, timestamp

getLimitOrderHistory(params?)

Limit and stop order events — Placed, Updated (re-priced), Canceled, Executed — newest first, including orders that are no longer open. A cancelled limit creates no order row, so neither getOpenOrders() nor getOrders() shows it; this does.

const events = await client.getLimitOrderHistory();
// Only cancels, last 50:
const cancels = await client.getLimitOrderHistory({ events: ['Canceled'], limit: 50 });

// Each event: event, limitId, pairId, pairTo, pairFrom, trader, idx, limitPx,
// collateralUsed?, leverage?, ntl?, side?, orderType?, oid?, hash, block, timestamp

limitId is the slot (<trader>_<pairId>_<idx>), the same value as Fill.limitId on the position an Executed event opened; oid on that event is the fill's oid. A slot is reused once its order ends, so one order's history runs from a Placed event to the next Canceled or Executed on the same limitId. The subgraph keeps side and order type only for the order currently in each slot, so side and orderType are omitted on events from earlier orders in a reused slot. Updated events only re-price, so they carry no size fields. limit must be a positive integer. Events the indexer has not linked to a slot are left out rather than reported as slot 0.

getOrders(params?)

Orders at any status — pending, executed, or cancelled. For fast user-facing confirmations after openTrade / closeTrade, prefer streamAccountUpdates(); use getOrders() as a lower-level lookup or fallback when you need specific order ids or submission transaction hashes.

const result = await client.openTrade({ /* … */ });

// Poll by the submission tx (matches subgraph `initiatedTx`):
const [order] = await client.getOrders({ initiatedTxHashes: [result.txHash] });
console.log(order.isPending, order.isCancelled);

// Or by on-chain order id:
const [byId] = await client.getOrders({ orderIds: [123] });

// Recent orders for the connected trader (no filter args):
const orders = await client.getOrders();

// Global orders and additional filters:
const globalOrders = await client.getOrders({ user: 'ALL' });
const builderOrders = await client.getOrders({ builder: '0xBuilder...' });
const pendingBtcOrders = await client.getOrders({
  isPending: true,
  pairIds: ['0'],
});
const cancelledBuilderOrders = await client.getOrders({
  builder: '0xBuilder...',
  isCanceled: true,
});
const recentBuilderOrders = await client.getOrders({
  builder: '0xBuilder...',
  start: Math.floor(Date.now() / 1000) - 7 * 86_400,
  end: Math.floor(Date.now() / 1000),
});

Each Fill includes builder, ntl (USD notional), trader, and timestamp (execution time, Unix seconds UTC — subgraph executedAt); each Order extends Fill with initiatedTx, initiatedTime, isPending, isCancelled, and optional cancelReason. time is retained as an alias for timestamp.

start / end are inclusive bounds on executedAt (matching getFillsByTime). Only executed orders have an execution time, so combining them with isPending: true matches nothing.

streamPositionUpdates(initial, priceStream?)

Re-prices an existing getOpenPositions() response as prices move.

This is a re-pricer, not a feed. It recalculates unrealizedPnl, returnOnEquity, ntl and maxWithdrawable for the positions you hand it. It does not poll, so a trade opened after the stream was created never appears, and liquidationPx does not move either — that tracks accrued rollover, not price.

Call refresh() after a fill or close to track the new set; it also adjusts the price subscription to the new pairs:

const stream = client.streamPositionUpdates(await client.getOpenPositions({ user }));
stream.onUpdate(({ pairPositions }) => render(pairPositions));

await client.openTrade({ ... });
// …once the fill lands
stream.refresh(await client.getOpenPositions({ user }));

For a stream that discovers trades on its own, use streamAccountUpdates().

streamAccountUpdates(params?)

Low-latency account confirmation stream. It emits the full processed account snapshot keyed by normalized trader address whenever pending orders, active limit/stop orders, or open positions change:

const stream = client.streamAccountUpdates();

stream.onUpdate(snapshot => {
  const account = snapshot[client.getTraderAddress().toLowerCase()];
  console.log(account.positions); // open PairPosition[]
  console.log(account.orders);    // pending Order[]
  console.log(account.limits);    // active OpenOrder[]
  console.log(account.closeExecutions); // recent CloseExecution[]
});
stream.onError(console.error);

// Clean up:
stream.close();

For frontend-style latency, insert a local optimistic market open before or while submitting, then attach the on-chain orderId as soon as your receipt watcher sees the oracle request log:

import { extractOrderIdFromReceipt } from '@ostium/builder-sdk';

const localId = stream.addOptimisticOpen(openParams);
const result = await client.openTrade(openParams);

// Wherever your app waits for the EOA receipt or UserOp receipt:
const orderId = extractOrderIdFromReceipt(receipt, client.getTraderAddress());
if (orderId) {
  stream.attachOrderId(localId, orderId, { initiatedTx: result.txHash });
}

Optimistic stream positions include position.confirmationStatus ("optimistic", "initiated", or "executed") plus orderId once known. Only "executed" overlays and indexed positions carry a trustworthy position.idx. "optimistic" and "initiated" overlays use idx: -1; do not pass those entries to closeTradeMarket, TP/SL updates, or collateral updates until they are executed or indexed. Indexed positions returned by normal read methods are unchanged.

The stream polls the account subgraph snapshot every 3 s by default (one batched query regardless of how many traders are subscribed — override with pollIntervalMs), opens a live price subscription immediately, and uses Arbitrum contract logs to show market opens and close executions before the subgraph indexes them, so confirmations stay sub-second at any poll interval. Full closes remove the position immediately; partial closes are reported in closeExecutions and the open position is reconciled on the next poll. Open trades are repriced on live ticks so PnL stays fresh between subgraph polls. Configure rpcWsUrl on the client or override it per stream. rpcHttpUrl covers the missed-event sweep and block reads, defaulting to rpcUrl:

const stream = client.streamAccountUpdates({
  user: ['0xTraderA...', '0xTraderB...'],
  rpcWsUrl: 'wss://your-arbitrum-node/ws',   // or alchemyApiKey: 'key'
  pollIntervalMs: 1000,
});

Read-only clients and standalone OstiumSubgraphClient calls require an explicit user address array:

const reader = await OstiumClient.createReadOnly({ rpcWsUrl: 'wss://your-arbitrum-node/ws' });
const stream = reader.streamAccountUpdates({ user: ['0xTrader...'] });

getBuilderOrders(builder, params?)

Fetch the full order history for trades routed through a builder. The subgraph only stores builder on open orders; this method supplements those with sibling close/TP/SL orders on the same positions (pid).

const orders = await client.getBuilderOrders('0xBuilder...');

// Inherits getOrders filters (user, isPending, isCanceled, pairIds, start, end, limit)
const pending = await client.getBuilderOrders('0xBuilder...', {
  isPending: true,
  pairIds: ['0'],
  limit: 50, // caps phase-1 builder-tagged orders; siblings are appended without cap
});

Sibling orders from phase 2 have builder stamped to the requested address. Use getOrders({ builder }) when you only need orders that have builder set on the subgraph (typically opens).

previewOpenTrade(params)

Everything an order ticket needs before a trade is signed — execution price after dynamic spread, the fee breakdown, collateral left backing the position, the resulting liquidation price, and the checks that would otherwise revert on-chain.

Derived from @ostium/formulae, the same math the contracts and ostium.io run, so your numbers match the official frontend. Call this before every openTrade.

const preview = await client.previewOpenTrade({
  pairId: 0,
  isLong: true,
  collateral: 1000,       // USDC, gross — fees come out of this
  leverage: 10,
  limitPrice: 95_000,     // optional; omit for a market order
  isDayTrade: false,      // optional; unlocks the pair's higher intraday cap
});

preview.entryPx;             // execution price after spread
preview.priceImpactP;        // spread paid, as a percentage
preview.spreadDecaySeconds;  // until the imbalance spread clears
preview.fees.total;          // openFee + oracleFee, deducted at open
preview.collateralAtOpen;    // what actually backs the position
preview.exposure;            // notional USD
preview.positionSize;        // base-asset units
preview.liquidationPx;
preview.effectiveMaxLeverage;
preview.withinExposureLimit; // undefined if the vault read failed
preview.isValid;             // false when `warnings` is non-empty

Warning codes: LEVERAGE_ABOVE_MAX, LEVERAGE_BELOW_MIN, BELOW_MIN_POSITION_SIZE, FEES_EXCEED_COLLATERAL, ABOVE_EXPOSURE_LIMIT, MARKET_CLOSED.

Note the shapes differ from submission: preview takes numbers and isLong; openTrade takes decimal strings and buy.

previewCloseTrade(params)

The exit mirror of previewOpenTrade(). Call it before closeTrade().

const p = await client.previewCloseTrade({ pairId: 0, idx: 0, closePercent: 50 });

p.exitPx;      // after spread — a long exits into the bid
p.spreadCost;  // what the spread costs to exit, isolated
p.rollover;    // rollover this close settles
p.netPnl;      // after rollover
p.received;    // collateral released + net PnL

Everything splits proportionally: closing 25% releases a quarter of the collateral and settles a quarter of the accrued rollover.

spreadCost is PnL at the exit price minus PnL at mid, so it isolates what the spread costs — a number no slippage estimate gives you. It is negative on both sides, because closing always takes the opposite side of the book from opening.

Funding is reported as "0", consistent with Position.unrealizedPnl and getTradePnL, which exclude it throughout this SDK.

getMaxCollateral(params)

The largest collateral a trade can use, and what stops it going higher — the number a Max button fills in.

const { max, bound, limits } = await client.getMaxCollateral({
  pairId: 0, isLong: true, leverage: 10,
});

amountInput.value = max;
if (bound === 'balance') hint('Limited by your USDC balance');

Five ceilings are folded together, and bound names the binding one:

| bound | Meaning | |---|---| | balance | Trader's USDC balance | | allowance | USDC allowance to TradingStorage | | openInterest | The side's open-interest headroom, at this leverage | | groupCollateral | The group's share of vault liquidity | | perTradeMax | MAX_COLLATERAL_USD |

limits carries all of them, so a UI can explain why the number stopped where it did. On a read-only client with no user, balance and allowance are omitted and only the protocol ceilings apply.

The two protocol ceilings are derived from the same inequalities WithinExposureLimit enforces on-chain, so max sits exactly on the boundary: one unit more fails the check. Note maxCollateralP caps the group's total collateral rather than a single trade, so headroom depends on what the group is already using — which is why this can come back far lower than the group cap alone suggests.

max is point-in-time: the open-interest cap is denominated in USD and moves with the mark price, so a value sitting exactly on that boundary can be rejected a second later. Re-run previewOpenTrade() before enabling submit.

getVaultBalance()

Protocol vault balance in USDC — the liquidity backing trader PnL, and the cap on per-pair open interest. Cached for 60 seconds.

const balance = await client.getVaultBalance();   // 12109444.289

getSimSlippage(params)

Simulate price impact for a list of pairs and notionals.

const result = await client.getSimSlippage({
  pairIds: [0, 1],
  ntls: ['1000', '10000', '100000'],
});
// result['0'].long  → [{ ntl, slippage }, ...]
// result['0'].short → [{ ntl, slippage }, ...]

getSimOrderbook(params)

Synthetic bid/ask orderbook for a pair, following Hyperliquid L2Book format. Levels are log-spaced from the minimum order size to the remaining OI capacity on each side.

const book = await client.getSimOrderbook({ pairId: 0, levels: 20 });
// book.levels[0] → bids (short entries), best bid first
// book.levels[1] → asks (long entries), best ask first
// Each level: { px: string, sz: string, n: 1 }

getCandles(params)

OHLC candles from the builder API. from/to are Unix milliseconds. pairId is the same value returned by getPairs(). Pass sets to fetch multiple candle pages — only needed if a single response doesn't cover the requested range; the API currently returns the full allowed range in one page, so sets > 1 is rarely required.

const candles = await client.getCandles({
  pairId: 0,
  from: Date.now() - 30 * 86_400_000,  // last 30 days
  to: Date.now(),                       // optional, defaults to now
  resolution: '1D',                     // '1' | '5' | '15' | '60' | '240' | '1D'
  sets: 3,                              // optional, defaults to 1 page
});
// Each candle: { pairFrom, pairTo, time, open, high, low, close }
// pairFrom / pairTo use normalized display names (e.g. "WTI" not "CL")

getPriceChanges24h(params?)

Each pair's change over the last 24 hours: the live mid against the price 24 hours ago from Ostium's price-history service, which is what the Ostium app shows. changePct is (price - price24hAgo) / price24hAgo * 100, so -1.41 means down 1.41%. Pass pairIds to restrict it.

const changes = await client.getPriceChanges24h({ pairIds: [0, 1, 10] });
// Each: { pairId, pairFrom, pairTo, price, price24hAgo, change, changePct }
// e.g.  { pairId: '0', pairFrom: 'BTC', pairTo: 'USD', price: 83034.96,
//         price24hAgo: 84221.73, change: -1186.77, changePct: -1.41 }

A pair is left out when it has no live price or no 24-hour-ago price, rather than reported as 0%. A call is one request to the price-history service plus the cached live prices, about 2 seconds. This is not the same as comparing against getCandles, whose series can start a little less than 24 hours back.

getRolloverHistory(params)

A pair's rollover over time, for cost-to-hold charts. Pair.rolloverRate is only the current rate and the subgraph keeps no history, so this reads the contract's own accumulator-update events over your chain RPC (rpcHttpUrl, else alchemyApiKey, else rpcUrl, else the public endpoint). Every trade on a pair emits one.

const history = await client.getRolloverHistory({
  pairId: 0,
  from: Date.now() - 7 * 86_400_000,
  to: Date.now(),             // optional, defaults to now
  intervalMs: 6 * 3_600_000,  // optional, defaults to 1 hour
});
// Each point: { timestamp (Unix s), block, accLong, accShort, rate8h: { long, short } }

rate8h is the average 8-hour rate as a percentage over the span since the previous point, in Pair.rolloverRate's sign convention (negative = trader pays). Every returned point lies within from–to. Its rate is measured from the reading before it, which can sit just before from; the very first reading only ever serves as that baseline. A quiet pair has no update events between trades; its rate across that gap is the average over it. accLong / accShort are the raw accumulators, the same values as Pair.rollover, so rolloverFee() works on any point.

A call makes a handful of eth_getLogs requests, split to fit the provider's range limit, plus one eth_getBlock per point; a week at 6-hour spacing takes about 7 seconds on the public RPC. It throws as soon as the window would produce more than 2000 points; raise intervalMs. A provider that refuses a block range or response size is retried with smaller ranges, down to a single block, but the call gives up after 400 eth_getLogs requests and tells you to set rpcHttpUrl to a provider with wider ranges. Rate limits, timeouts and 5xx responses are retried a few times with backoff on the same range, then thrown; they never shrink the range. pairId must be a numeric id: an empty or non-numeric value throws INVALID_PARAMS rather than falling back to pair 0.

streamPrices(pairIds?)

Reconnects on its own. A dropped socket is reopened with backoff (1s, 2s, 4s, 8s… capped at 30s) and the subscription filter is re-sent, because a reconnected socket starts with none — without that the stream comes back silent, which looks exactly like a quiet market.

onTick, onSnapshot, onError, onClose and onOpen handlers survive the reconnect. onOpen fires again each time the link is re-established, and onClose fires on every drop including recoverable ones — read it as "the link went down", not "the stream is finished". close() is final and cancels any pending reconnect.

Subscriptions are reference-counted: if two parts of your app subscribe to the same pair, the server is told once, and told to unsubscribe only when the last of them lets go. Previously the first unsubscribe stopped ticks for both.

WebSocket connection to the live price feed. Fires a snapshot on connect, then a tick on every update. Accepts the same pairId values as everywhere else in the SDK — no pair name strings needed.

const stream = client.streamPrices([0, 1]);  // BTC and ETH by pairId

stream.onOpen(() => console.log('connected'));
stream.onSnapshot(ticks => console.log('initial snapshot:', ticks.length));
stream.onTick(tick => console.log(tick.pair, tick.mid));
// tick.pair / tick.from / tick.to use normalized display names
// ticks also carry isMarketOpen / isDayTradingClosed / secondsToToggleIsDayTradingClosed,
// plus the pair's market-hours `schedule` when reported by the feed

// Dynamically add / remove by pairId:
stream.subscribe([2]);
stream.unsubscribe([0]);

// Clean up:
stream.close();

Requires Node.js 18+, Bun, or a browser environment.

Getters

client.getTraderAddress();       // on-chain trader address (throws in read-only mode)
client.getSmartAccountAddress(); // Safe address — gasless modes only, else undefined
client.isReadOnly();             // true when created via createReadOnly()

Error Codes

import { OstiumError, OstiumErrorCode } from '@ostium/builder-sdk';

| Code | When thrown | |:--|:--| | INVALID_CONFIG | Missing or malformed config (privateKey format, address format, fee range) | | VALIDATION_FAILED | Invalid trade params (TP/SL direction, leverage range, close percent) | | ALLOWANCE_INSUFFICIENT | USDC allowance too low for openTrade / updateCollateral | | DELEGATION_FAILED | approveUsdc() called in delegated mode | | CONTRACT_ERROR | Contract revert (e.g. WrongLeverage, NoDelegate) | | NETWORK_ERROR | RPC / Pimlico connectivity issues or rate limits | | SUBMISSION_FAILED | Transaction rejected for other reasons |

Decimal Utilities

import { parseUsdc, parsePrice, parseLeverage, MIN_OPEN_SIZE_USD, MAX_COLLATERAL_USD } from '@ostium/builder-sdk';

parsePrice('65000.50');   // 65000500000000000000000n (18 decimals)
parseUsdc('100.50');      // 100500000n (6 decimals)
MIN_OPEN_SIZE_USD;        // 5
MAX_COLLATERAL_USD;       // 2_000_000

Standalone Math

Pure functions over plain numbers — no client, no network. For bots, backtesters, and risk dashboards.

import { liquidationPrice, pnl } from '@ostium/builder-sdk';

liquidationPrice({
  entryPx: 100_000, isLong: true, collateral: 1000, leverage: 10, maxLeverage: 100,
  rollover: 0, funding: 0,   // optional; accrued fees move liquidation toward entry
});

pnl({ entryPx: 100_000, markPx: 105_000, isLong: true, collateral: 1000, leverage: 10 });
// → { netPnl: 500, netPnlPercent: 50, netValue: 1500 }

For positions you already fetched, don't recompute — Position carries unrealizedPnl, liquidationPx and returnOnEquity already.

createStore() — pairs, prices and positions in one live object

Replaces the glue every integration otherwise writes by hand: open the price socket, poll pairs for rollover accumulators, refetch positions when they change, recompute PnL and liquidation on every tick, and throttle it so a busy market does not flatten the UI.

const store = client.createStore();

const unsubscribe = store.subscribe(({ ready, positions, marginSummary }) => {
  if (!ready) return;
  render(positions, marginSummary);    // PnL and liq already current
});

await client.openTrade({ ... });
await store.refresh();                 // pick the new position up at once

unsubscribe();
store.close();

Each input moves at its own cadence, for a reason:

| Input | Cadence | Why | |---|---|---| | prices | websocket | this is what actually moves | | pairs | 60 s poll | rollover accumulators are global, so one poll serves every trader | | positions | 15 s poll | membership only changes when a trade opens or closes |

Derived fields are recomputed locally, so a price tick costs no network. Emissions are coalesced to throttleMs (250 ms default; set 0 to emit on every tick), and the store resubscribes the socket as positions come and go.

The recompute calls the same exported helpers you could call yourself — rolloverFee, pnl, liquidationPrice — so this is a cadence and caching layer, not a second copy of the math. Verified against live mainnet: the store's repriced figures match getOpenPositions() to within 1e-6.

StorePosition adds one field to Position: rolloverAccrued, the rollover as of state.blockNumber.

A failed poll sets state.lastError and leaves that field stale; the store keeps running. Errors are tracked per source, so a healthy positions poll never masks a failing pairs poll.

Live Liquidation Price Without Per-Trader Polling

Position.liquidationPx and unrealizedPnl are correct at fetch time, and rollover keeps accruing per block after that. Rather than re-querying positions for every trader, poll getPairs() once — the accumulators are global — read a block number from your own RPC, and advance each position locally:

import { rolloverFee, liquidationPrice } from '@ostium/builder-sdk';

const { pairs } = await client.getPairs();                  // slow global poll
const byPairId = new Map(pairs.map(p => [p.pairId, p]));
const blockNumber = await publicClient.getBlockNumber();    // your own RPC

const { pairPositions } = await client.getOpenPositions({ user });

for (const { position } of pairPositions) {
  const pair = byPairId.get(position.pairId);
  if (!pair) continue;

  const isLong = position.side === 'B';
  const accrued = rolloverFee({
    rollover: pair.rollover,                   // raw accumulators
    isLong,
    snapshot: position.rolloverSnapshot,       // accumulator when it opened
    collateral: Number(position.collateralUsed),
    leverage: Number(position.leverage),
    blockNumber,
  });

  const liq = liquidationPrice({
    entryPx: Number(position.entryPx),
    isLong,
    collateral: Number(position.collateralUsed),
    leverage: Number(position.leverage),
    maxLeverage: Number(position.maxLeverage),
    rollover: accrued,
  });
}

That is O(1) per venue instead of O(N) per trader. Verified against mainnet: rolloverFee() reproduces the SDK's own cumRollover exactly.

Standalone Subgraph Client

For read-only market data without a trading key, use OstiumSubgraphClient directly:

import { OstiumSubgraphClient } from '@ostium/builder-sdk';

const subgraph = await OstiumSubgraphClient.create({ testnet: false });

const { pairs }    = await subgraph.getPairs();
const { prices }   = await subgraph.getAllPrices();
const positions    = await subgraph.getOpenPositions({ user: '0xTrader...' });
const fills        = await subgraph.getFills({ user: '0xTrader...', limit: 50 });

OstiumSubgraphClient.getOpenPositions accepts an optional blockNumber (required for accurate PnL). On OstiumClient this is fetched automatically.