@ostium/builder-sdk
v0.12.0
Published
Ostium builder SDK
Readme
Ostium Builder SDK

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 viemGuides:
- 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.tsBuilding 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 --noEmitModes
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=42161When 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: truewhen the desired leverage exceedspair.overnightMaxLeverage(andovernightMaxLeverage > 0). UsegetPairs()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 $30USDC + 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?, timestampgetLimitOrderHistory(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, timestamplimitId 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-emptyWarning 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 PnLEverything 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.289getSimSlippage(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_000Standalone 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.
