Api

Orderbook API

Markets, orders, trades, streaming market and DEX events

The Orderbook API exposes Hydra App's OrderbookService — initialised markets, the live orderbook, your own orders + trades, and two streaming surfaces (SubscribeMarketEvents for public market data, SubscribeDexEvents for your account activity).

JSON-RPC namespace: orderbook

Endpoints

Markets

Venue economics

Orders

Trades (public + per-account)

Streams


Shared types

OrderbookCurrency

Identifies an asset for orderbook RPCs (note: distinct from Network — flat shape).

FieldTypeNotes
protocolProtocol enumPROTOCOL_BITCOIN or PROTOCOL_EVM
network_idstringMagic bytes (Bitcoin) or decimal chain ID (EVM)
asset_idstringSee asset_id format below

asset_id format

Verified live against a running Hydra App; do not use the ticker.

AssetForm
Native asset (BTC, ETH, …)Zero-padded 32-byte hex: 0x0000000000000000000000000000000000000000000000000000000000000000
ERC-20 tokenerc20:<lowercase-contract-address> (e.g. erc20:0x8cd0da3d001b013336918b8bc4e56d9dda1347e0)

The asset RPCs (asset_getAsset / asset_getNativeAsset) return the canonical form; if in doubt, fetch from there.

OrderAmount (oneof)

Used as the amount field on order requests and as the persisted-form remaining_amount / unmatched_amount / failed_amount on response orders. Exactly one variant:

VariantPayloadMeaning
base{ amount: DecimalString }Amount denominated in the base currency
quote{ amount: DecimalString }Amount denominated in the quote currency

DecimalString is always human-readable (e.g. "0.001" BTC, never satoshis). See Common Patterns → Amounts & Decimals.

⚠️ OrderAmount echoes the denomination you placed the order in

OrderAmount is a oneof precisely so it can carry either denomination. The side of an order (buy/sell) does not determine it. When you create a LimitOrder or MarketOrder, you choose base or quote freely — a buy can be sized in base or quote, and so can a sell. On response orders, remaining_amount, unmatched_amount, and failed_amount come back in the same variant you submitted, reduced as the order fills. A buy placed in base reports base remaining; a buy placed in quote reports quote remaining.

Always discriminate on the oneof variant actually set — never on the side:

  • Python: WhichOneof('amount') (hasattr is useless on oneofs — it's always true).
  • TypeScript: getAmountCase() / hasBase() / hasQuote().
  • Go: type-switch on *pb.OrderAmount_Base_ vs *pb.OrderAmount_Quote_.
  • Rust: match amount.amount { Some(order_amount::Amount::Base(_)) | Some(order_amount::Amount::Quote(_)) => … }.

Do not confuse this with LiquidityPosition.amount in the public orderbook (GetOrderbook). That field is a single scalar reporting offered liquidity, so it genuinely is base-on-sell / quote-on-buy. The remaining_amount / unmatched_amount / failed_amount on your own order do not follow that rule — they follow your placement choice.

Enum values (full names)

JSON-RPC and gRPC responses use the full proto constant names. Match these exactly:

EnumValues
OrderSideORDER_SIDE_UNSPECIFIED (0), ORDER_SIDE_BUY (1), ORDER_SIDE_SELL (2)
OrderTypeORDER_TYPE_UNSPECIFIED (0), ORDER_TYPE_LIMIT (1), ORDER_TYPE_LIQUIDITY (2), ORDER_TYPE_MARKET (3), ORDER_TYPE_SWAP (4)
AmountSideAMOUNT_SIDE_UNSPECIFIED (0), AMOUNT_SIDE_BASE (1), AMOUNT_SIDE_QUOTE (2)
TimeInForceTIME_IN_FORCE_UNSPECIFIED (0), TIME_IN_FORCE_GTC (1), TIME_IN_FORCE_POST_ONLY (2), TIME_IN_FORCE_IOC (3), TIME_IN_FORCE_FOK (4)
SelfTradePreventionSELF_TRADE_PREVENTION_UNSPECIFIED (0), SELF_TRADE_PREVENTION_NONE (1), SELF_TRADE_PREVENTION_CANCEL_TAKER (2), SELF_TRADE_PREVENTION_CANCEL_MAKER (3), SELF_TRADE_PREVENTION_CANCEL_BOTH (4)
SwapRoleSWAP_ROLE_UNSPECIFIED (0), SWAP_ROLE_LAST_MAKER (1), SWAP_ROLE_INTERMEDIATE_MAKER (2), SWAP_ROLE_TAKER (3)
SwapStatusSWAP_STATUS_UNSPECIFIED (0) → SWAP_STATUS_SWAP_FAILED (9). See proto for the full list.
CandlestickIntervalCANDLESTICK_INTERVAL_UNSPECIFIED (0), ..._ONE_MINUTE (1), ..._THREE_MINUTES (2), … ..._ONE_MONTH (15)

SWAP_ROLE_TAKER was renumbered (0 → 3) on 2026-05-03. Re-map if you persisted raw integers.


Init Market

Initialise a new trading market. Idempotent — repeat calls with the same pair return the existing MarketInfo.

Method: InitMarket

ParamTypeDescription
first_currencyOrderbookCurrencyOne side of the pair
other_currencyOrderbookCurrencyThe other side

Response: { market_info?: MarketInfo } — present when the market was successfully initialised; absent if the pair is unsupported.


Get Initialized Markets

List every market initialised on this Hydra App.

Method: GetInitializedMarkets

Params: none.

Response: { markets: MarketInfo[] }.


Get Markets Info

Like GetInitializedMarkets but additionally fetches live MarketInfo (fees, precision, min amounts) for every market.

Method: GetMarketsInfo

Params: none.

Response: { markets: MarketInfo[] }.

MarketInfo:

FieldTypeDescription
baseCurrencyInfoBase currency + on-chain decimals
quoteCurrencyInfoQuote currency + on-chain decimals
taker_base_feeDecimalStringList taker fee ratio on the base side — see the note below
taker_quote_feeDecimalStringList taker fee ratio on the quote side
maker_base_feeDecimalStringList maker fee ratio on the base side. Negative = a rebate paid to the maker
maker_quote_feeDecimalStringList maker fee ratio on the quote side
base_precisionuint32Decimal places of precision for base amounts
quote_precisionuint32Decimal places of precision for quote amounts
min_base_amountDecimalStringStructural minimum, base side — see below
min_quote_amountDecimalStringStructural minimum, quote side
min_place_base_amountDecimalString?(2026-08-15) Advisory placement-floor snapshot, base side — see below
min_place_quote_amountDecimalString?(2026-08-15) Advisory placement-floor snapshot, quote side
order_min_notional_usdDecimalString?(2026-08-15) The pair's USD placement floor. Absent when disabled.
max_taker_discountDecimalString?(2026-09-04) Deepest discount a taker's fee on this market is struck at. 0 = takers pay list here
max_maker_discountDecimalString?(2026-09-04) Deepest discount a maker's rate on this market is struck at. 0 = makers trade at list here
stateMarketState(2026-09-16) Whether this market still takes orders — see below

These are list rates, not yours

MarketInfo advertises the fee ratios that apply to every client alike. What you are charged is these with your own fee-tier discount struck off. Don't derive that by hand — call GetMarketFeeRates, which returns a market's four ratios already priced for this node in both roles. A discount makes a fee shrink and a rebate deepen, and the naive listed × (1 − discount) gets the rebate case backwards.

The three optional fields marked ? above are genuinely absent on a server that does not stamp them — distinguish "absent" from "0".

state — whether the market is taking orders

(2026-09-16) MARKET_STATE_ENABLED (1) trades normally. MARKET_STATE_CANCEL_ONLY (2) refuses new limit and market orders but keeps resting orders and accepts cancels, and swap orders still route through the market, taking its resting orders — that is how it drains. MARKET_STATE_FROZEN (3) additionally closes it to swap routing: nothing new matches there, while resting orders can still be cancelled and swaps already matched settle. MARKET_STATE_UNSPECIFIED (0) comes only from a hub that predates the field.

MARKET_STATE_POST_ONLY (4) takes new limit orders only, every one of them post-only: one that would take an order on offer is refused with post_only_would_cross. Market and swap orders are refused, and no swap routes through the market; resting orders stand and may be cancelled. An operator reopens a market through it after maintenance, so the book refills without crossing.

A market allows only what both of its currencies' states allow: it goes cancel-only the moment either side does, and a market with one post-only side (new limit orders, no routing) and one cancel-only side (routing, no new orders) takes neither — it reads MARKET_STATE_FROZEN. Compare states by name, never by number: the numbers follow no order.

Update your client before a market goes post-only. A client that fails on an enum value it does not know cannot read a MARKET_STATE_POST_ONLY market at all; the operator puts no market in that state until clients are updated. Read a state you do not know as FROZEN.

It hot-reloads, so a cached copy goes stale silently. The operator changes it by editing the hub's asset registry — no restart, no disconnect, nothing your client would notice. Subscribe to market_info on the market stream and replace your copy when it arrives, rather than learning about it from a market_state rejection. Both GetMarketsInfo and GetMarketInfo stamp it; a MarketInfo embedded anywhere else may not.

Two different minimums

An order has to clear both floors, and they behave differently:

Structural minimum — min_base_amount / min_quote_amount. The market's grid minimum: the smallest amount a single fill may carry, and the minimum size of an order of any type. It is stable and authoritative. A partially consumed maker whose remainder would fall below it is evicted at match time, which is what guarantees that any resting size ≥ this minimum is fully takeable.

Advisory placement floor — min_place_base_amount / min_place_quote_amount. A snapshot of the hub's USD placement floor (order_min_notional_usd) converted to base/quote units at the hub's last oracle read. It is advisory only — it moves with oracle prices, so treat it as a sizing hint, not a contract. It is absent when the floor is disabled, when a side is unpriceable, or when the serving endpoint does not stamp it (notably the market-list RPC, GetMarketsInfo); size by the structural minimums in that case.

The authoritative placement floor is the rejection, not the snapshot. When an order is refused for being too small, the rejection carries the live min_place= value. Re-read it from there and refetch GetMarketInfo — see Errors → Placement rejections. Sizing purely off a cached min_place_* will intermittently fail as prices move.


Get Market Info

Same shape as GetMarketsInfo, scoped to one (first_currency, other_currency) pair.

Method: GetMarketInfo

Params: { first_currency: OrderbookCurrency, other_currency: OrderbookCurrency }

Response: { market_info?: MarketInfo }.


Get Market Fee Rates

(2026-09-03) The four fee ratios this node trades a market at — the market's published rates with this node's own discounts already struck off, each role at its own figure. Read from the same market copy the node's own estimates price against.

Method: GetMarketFeeRates

Params: { first_currency: OrderbookCurrency, other_currency: OrderbookCurrency }

Response: { fee_rates?: MarketFeeRates } — absent when the pair is not a market.

MarketFeeRates:

FieldTypeDescription
taker_base_feeDecimalStringCharged on the base this node receives when it takes liquidity
taker_quote_feeDecimalStringCharged on the quote this node receives when it takes liquidity
maker_base_feeDecimalStringCharged on the base this node receives when one of its resting orders fills. Negative = a rebate paid to this node
maker_quote_feeDecimalStringCharged on the quote this node receives when one of its resting orders fills
taker_discountDecimalStringThe discount the taker rates above were struck at, after bounding by MarketInfo.max_taker_discount. 0 when this node holds no rung or the market honours none
maker_discountDecimalStringThe discount the maker rates were struck at, after bounding by MarketInfo.max_maker_discount

This is a snapshot. Your discounts move as you settle volume and as the operator edits the ladders, and a fill is priced at whatever you hold when it fills. Refresh on a fee_discount_update event rather than caching indefinitely.


Get Currencies

(2026-09-02) The orderbook operator's asset allowlist — every currency it trades, with its ticker, class and lifecycle state.

Method: GetCurrencies

Params: none.

Response: { currencies: ListedCurrency[] } — ordered by network, then ticker.

ListedCurrency:

FieldTypeDescription
currencyOrderbookCurrencyThe currency itself
tickerstring?The currency's identity on the orderbook — what fee rules and holding ladders are keyed on. Absent for a currency found in a persisted market but no longer listed
classCurrencyClassCURRENCY_CLASS_STANDARD (1) or CURRENCY_CLASS_STABLECOIN (2)
stateCurrencyState..._ENABLED (1), ..._CANCEL_ONLY (2), ..._FROZEN (3), ..._POST_ONLY (4)
min_notional_usdDecimalString?This currency's own USD placement floor. Policy input, not the floor an order is checked against
min_native_amountDecimalString?A floor in the currency's own units, always in effect

The same ticker on two networks is one asset bridged across chains — that is the point of the ticker, and it is deliberately distinct from the symbol the chain reports. A holding ladder for HDN counts your HDN wherever it lives.

Lifecycle states:

StateNew ordersCancels & in-flight settlementSwap routing
ENABLED✅✅✅
CANCEL_ONLY❌✅✅
FROZEN❌✅❌
POST_ONLYLimit orders only, every one of them post-only✅❌

A market takes what both of its currencies' states allow — see market states. Read a state you do not know as FROZEN.

Both enums reserve their zero value and are never sent. JSON drops a default-valued field, so a real variant at 0 would be indistinguishable from an absent one — treat CURRENCY_CLASS_UNSPECIFIED / CURRENCY_STATE_UNSPECIFIED as a conversion error, not a default. A currency with no ticker is always CANCEL_ONLY and carries STANDARD (having no configured class) plus none of the floors.

Currency floors, most specific first: the currency's own min_notional_usd wins over the network's, which wins over the orderbook-wide one. min_native_amount is taken alongside the USD floor and binds whenever it is the larger of the two — so a market keeps a minimum even when the oracle is absent or wrong. To size an actual order, read the market's min_base_amount / min_place_base_amount; these are the policy inputs behind them.


Fee discounts

(2026-09-02, reshaped 2026-09-04) The operator runs up to two kinds of discount ladder, and what a fill costs you depends on which rungs you hold. Three RPCs cover it:

RPCAnswers
GetFeeDiscountProgramsWhat ladders exist, as configured
GetFeeDiscountWhere you stand on them
GetMarketFeeRatesWhat one market costs you, both roles, already priced

The arithmetic

A discount is a fraction applied to your own rate in that role, in your favor:

effective = listed − discount × |listed|

Applying it to a signed rate is the whole trick: a positive rate (a fee) shrinks, a negative one (a rebate) deepens. The intuitive listed × (1 − discount) is only correct for fees — on a maker rebate it moves the number the wrong way.

A discount never touches a counterparty's rate, and each market bounds each role's discount by its own published maximum (MarketInfo.max_taker_discount / max_maker_discount). Your volume and holding rungs sum.


Get Fee Discount Programs

The ladders the operator runs, as configured. An absent volume or an empty holdings means that program does not run.

Method: GetFeeDiscountPrograms

Params: none.

Response:

FieldTypeDescription
volumeVolumeDiscountProgram?The volume ladder
holdingsHoldingDiscountProgram[]One entry per holding ladder
max_taker_discountDecimalString?Operator's default ceiling on a market's taker discount. Absent = only the ladders' own maxima bound it
max_maker_discountDecimalString?Same for the maker discount

A market's own terms may replace either ceiling for that market — MarketInfo.max_taker_discount / max_maker_discount is the result.

VolumeDiscountProgram — rolling weighted settled USD volume buys a rung: { window_days: uint32, tiers: VolumeDiscountTier[] } (ascending). VolumeDiscountTier — { from_volume_usd, maker_discount, taker_discount }; from_volume_usd is an inclusive threshold.

HoldingDiscountProgram — holding an asset above a threshold buys a rung: { ticker, currencies: OrderbookCurrency[], tiers: HoldingDiscountTier[] }. currencies is exactly the set whose balances count as holding the asset, summed across them. HoldingDiscountTier — { from_amount, maker_discount, taker_discount }, in the asset's native units, inclusive.


Get Fee Discount

Your own standing on those programs, and the two figures your fills are priced at.

Method: GetFeeDiscount

Params: none.

Response: { standing: FeeDiscountStanding }

FeeDiscountStanding is a oneof — exactly one is set:

VariantMeaning
no_programThe operator runs no discount program: every fill is list price
excludedThis node is excluded: list price in both roles, and its settled volume earns nothing
activeActiveFeeDiscount — your standing on the programs that run

ActiveFeeDiscount:

FieldTypeDescription
taker_discountDecimalStringThe taker columns of your volume rung and every holding rung, summed — what your taker fills are priced at on a market with no ceiling
maker_discountDecimalStringThe same for your maker fills
volumeVolumeDiscountStanding?Absent when the operator runs no volume ladder
holdingsHoldingDiscountStanding[]One per configured holding ladder; empty when none run

taker_discount / maker_discount here are figures to show, not to price with. Every market bounds them by its own maximum. To price an order, call GetMarketFeeRates — it returns the market's rates already struck at the bounded figure.

VolumeDiscountStanding: { window_days, settled_volume_usd, tier: uint32, maker_discount, taker_discount, next_tier?: VolumeDiscountTier }. tier is 1-based; 0 is the undiscounted base. next_tier is absent at the top of the ladder. settled_volume_usd counts every settled hop for its taker and its maker alike, at its own notional scaled by its market's volume weight; a self-trade counts once.

HoldingDiscountStanding: { ticker, held_amount, by_currency: CurrencyHolding[], tier: uint32, maker_discount, taker_discount, next_tier?: HoldingDiscountTier, measured_at?: Timestamp }. tier 0 means below the first threshold; measured_at is absent until the first measurement.

CurrencyHolding: { currency, channel_amount, onchain_amount? } — what the hub measured of one counted currency. channel_amount is your own side of your channels with the hub. onchain_amount is the token balance at your node's wallet address, and is absent on networks whose node ids are not wallet addresses (Bitcoin), or when the last read failed with no earlier figure to carry.

Your standing arrives by subscription, not by polling. The hub opens the private DEX stream with your standing and pushes the whole standing again whenever it moves — see fee_discount_update. Silence means unchanged; the last message received is the one to hold. Call GetFeeDiscount to seed a UI, then follow the stream.


Get Sub-Economic Policy

(2026-09-16)

Method: GetSubEconomicPolicy

Params: none.

Response: { policy?: SubEconomicPolicy } — absent when the hub prices no network, which means nothing is sub-economic anywhere and none of this applies.

What the venue bounds

Nothing is refused for being small. What the venue bounds is what your concurrent small swaps would cost the hub to force-close if your node went dark. Every amount in this message is a quantity of that cost — never a trade notional, and reading them as notionals will be wrong by orders of magnitude.

A force-closure is billed in two dimensions, and both are published per network:

force_close_usd = asset_channel_close_usd × asset channels at risk
                + htlc_resolution_usd     × HTLCs at risk

summed over every network you hold channels on. The asset channel (channel, asset) is what one closure resolves: several small swaps on one of them share its close, but each leaves its own pending HTLC to resolve.

Escaping it entirely

A swap is not tracked at all when its own value covers force-closing its own legs in the worst case. With K the channels you hold with the hub for that asset:

untracked  ⇔  notional ≥ K × (asset_channel_close_usd + htlc_resolution_usd)

K, not one. Until a payment is actually sent it may still be MPP-split across every one of those channels, and recovering it then costs one closure in each. This is why the hub publishes the inputs rather than a single "safe notional" — that number is K × one closure, and K is yours, not the venue's. A client holding one channel and a client holding a hundred have thresholds two orders of magnitude apart on the same market.

The check

allowance = client_force_close_allowance_usd
          + min(earned_usd_per_usd_settled × settled_usd, earned_cap_usd)

refused   ⇔ Δforce_close_usd > 0
            ∧ force_close_usd + Δforce_close_usd > allowance

where settled_usd is what you have settled inside earned_window_days. A match that adds nothing — every leg landing on asset channels you already have at risk, on a network whose htlc_resolution_usd is zero — is admitted however full your allowance is.

SubEconomicPolicy:

FieldTypeDescription
by_networkSubEconomicNetworkPolicy[]Per-network pricing. A network with no entry has the mechanism off — nothing there is ever sub-economic
client_force_close_allowance_usdDecimalStringHow much force-closure cost a client with no settled volume may have at risk, across every network at once
earned_usd_per_usd_settledDecimalStringExtra allowance earned per USD settled inside the window. Zero means the ladder is off
earned_cap_usdDecimalStringCeiling on the earned part
earned_window_daysuint32Rolling window the settled volume is measured over

SubEconomicNetworkPolicy:

FieldTypeDescription
networkNetworkThe network this pricing applies to
asset_channel_close_usdDecimalStringWhat force-closing one asset channel costs the hub here, carrying no pending payments
htlc_resolution_usdDecimalStringWhat resolving one pending HTLC inside that closure costs on top. May be zero
peer_htlc_capuint32Most concurrent sub-economic HTLC legs one peer may hold here

peer_htlc_cap is not part of the cost arithmetic. What those HTLCs cost to resolve is already priced by htlc_resolution_usd and charged to your allowance. The cap bounds a different resource: HTLC slots are finite per channel, and a lane whose slots are full carries nothing — including the large payments the venue exists for. It has its own rejection, sub_economic_htlc_cap, which clears as the HTLCs resolve.

Every value here changes only when the hub restarts, so fetching once at startup is enough. A restart drops your connection, which is your signal to refetch.

What is deliberately not published. There is a venue-wide ceiling behind a separate sub_economic_venue_limit rejection — a backstop against a fleet of identities filling the hub's total, not something normal trading meets. It cannot be mirrored locally and is retryable, and it carries its own numbers on the rejection. Your own live figure is not pushed either: it is dominated by the worst-case projection above, which collapses within seconds of each payment reporting where it went, so a snapshot would be stale on arrival.


Get Orderbook Balances

Your balance on the venue in every currency you can trade.

Method: GetOrderbookBalances

Params: none.

Response: { balances: OrderbookBalance[] } — one entry per currency, each { currency: OrderbookCurrency, balance: CurrencyBalance }.

The *_sending figures are your side of your channels with the hub in that currency, what you pay the hub with; the *_receiving figures are the hub's side, what it pays you with.

FieldTypeDescription
sendingDecimalStringThe largest amount a new order can send now, the figure the venue admits an order against: what one payment over your channels that reach the floor can carry, less what your orders hold
receivingDecimalStringThe largest amount a new order can receive now: what one payment from the hub over the channels that reach the floor can carry, less what your orders hold, and no more than the hub can still forward to you
pending_sending / pending_receivingDecimalStringArriving from a deposit or channel open whose funding has not confirmed yet; spendable once it does
unavailable_sending / unavailable_receivingDecimalStringOwned but not spendable now: the channel reserve, payments in flight, balance being withdrawn or redeemed, and balance on channels that cannot pay
in_use_sending / in_use_receivingDecimalStringHeld by your open orders and the swaps they matched into
ineligible_sending / ineligible_receivingDecimalStringFree on channels whose payment deadline bound is below the floor, or not learned by the venue yet. No order can use it; a channel opened with a long enough dispute period can. 0 from a node that predates these fields

The floor. A channel counts toward sending and receiving only when its payment_deadline_bound_secs (GetChannelTerms) reaches the deadline of the last leg of the longest route the venue builds: 39 hours with the production ladder, so a channel opened with a 48-hour dispute period counts and a 24-hour one does not. The rule is the same for every order, resting, market or swap, on either side and over any route, so sending and receiving are exactly what an order is admitted against. A Lightning channel reports no bound and always counts. The Simple Swap opens and leases channels that reach the floor on its own.

A BalanceUpdate event carries the same CurrencyBalance whenever it changes.


Get Orderbook

Fetch the current snapshot of one orderbook.

Method: GetOrderbook

Params: { base: OrderbookCurrency, quote: OrderbookCurrency }

Response: { orderbook?: Orderbook }.

Orderbook:

FieldTypeDescription
infoMarketInfoMarket configuration
ordersmap<string, LiquidityPosition>Active liquidity positions, keyed by order_id

LiquidityPosition:

FieldTypeDescription
client_pubkeybytesEd25519 public key of the position owner
sideOrderSideBuy or sell
priceDecimalStringPrice (quote per base)
amountDecimalStringOffered — base amount on sell orders, quote on buy orders
matched_amountDecimalStringAmount already matched
pending_cancelboolTrue if a cancellation is in flight

Example:

import { GetOrderbookRequest } from './proto/orderbook_pb'

const SIGNET_BTC = { protocol: 1, networkId: '0a03cf40',
  assetId: '0x0000000000000000000000000000000000000000000000000000000000000000' }
const SEPOLIA_USDC = { protocol: 2, networkId: '11155111',
  assetId: 'erc20:0x8cd0da3d001b013336918b8bc4e56d9dda1347e0' }

const req = new GetOrderbookRequest()
req.setBase(SIGNET_BTC)
req.setQuote(SEPOLIA_USDC)

const resp = await orderbook.getOrderbook(req, {})
const ob = resp.getOrderbook()
if (ob) {
  ob.getOrdersMap().forEach((pos, orderId) => {
    console.log(orderId, pos.getSide(), pos.getPrice()?.getValue(), pos.getAmount()?.getValue())
  })
}

Get Market Daily Stats

Read one market's 24-hour statistics as this node mirrors them — the same figures a daily_stats_update on SubscribeMarketEvents carries, at any time. Unary.

Method: GetMarketDailyStats

Params: { base: OrderbookCurrency, quote: OrderbookCurrency } — the pair as the market names it.

Response: { stats?: MarketDailyStats } (MarketDailyStats). Absent when this node has not initialised the market (InitMarket).

Read it once your stream is open, then follow the stream. The venue sends a daily_stats_update when a trade happens or leaves the 24-hour window, so on a quiet market the stream alone can leave you without stats for a long time. Read them again on is_synced: true.

Example:

import { GetMarketDailyStatsRequest } from './proto/orderbook_pb'

const req = new GetMarketDailyStatsRequest()
req.setBase(SIGNET_BTC)
req.setQuote(SEPOLIA_USDC)

const stats = (await orderbook.getMarketDailyStats(req, {})).getStats()
if (stats) {
  const v = stats.getVolatility()
  console.log(v?.getLastPrice()?.getValue(), v?.getBaseVolume()?.getValue())
}

Estimate Order

Dry-run an order — see the matching it would produce without actually creating it.

Method: EstimateOrder

Params: { order_variant: OrderVariant } — same OrderVariant shape as CreateOrder.

Response: { order_match?: OrderMatch }. Absent when no matching is possible.

A limit order is estimated as its walk of the book would go: at its price or better, in its own amount (fee included), and — for TIME_IN_FORCE_FOK — not at all unless it fills in full; one that rests without taking has no estimate. A market order is estimated within its limit_price. The estimate reads your node's copy of the book: it does not know the market's state, and does not tell your own orders apart from others' for self-trade prevention.


Create Order

Place an order on the orderbook.

Method: CreateOrder

ParamTypeRequiredDescription
order_variantOrderVariantYESExactly one of LimitOrder / MarketOrder / SwapOrder
client_order_idstringNOAdded 2026-05-03. Your own identifier, max 64 chars. See idempotency note below.
settlementOrderSettlementNOPer-leg channel vs on-chain settlement. See On-chain settlement.
self_trade_preventionSelfTradePreventionNOWhat the order does at your own orders, on every market it trades on. Leave it unset for no prevention. See Self-trade prevention.

Response: { order_id: string, released?: OrderRelease }.

  • released is set when a limit order matched part of what it asked for at once and the rest could not rest: the order is placed at what it matched, and released says what was not placed and why. See Price priority. The order's OrderCreated event carries it too.
  • A request sent again under the client_order_id of its order gets the answer that order's placement gave, released included, and places nothing, so your retry after a lost answer reads as the answer it lost. The node's hub client retries a transport failure the same way, under the key it sent — one it makes up per request when you send no client_order_id.

Idempotent order creation. A client_order_id names one of your orders: from the request that places it, while the order lives, and for a retention period after it ends (24 hours by default; the hub's operator sets it). Send the same order again under it — the same order_variant, settlement and self_trade_prevention, every amount and price written the same way (1.0 and 1.00 differ) — and you get the answer that order's placement gave, live or ended — its order_id, and what it released — with nothing placed, so a network blip or DEADLINE_EXCEEDED retry never double-places. A different order under it is refused with ALREADY_EXISTS and the client_order_id_taken prefix, naming the order the id already names. A request the hub refuses frees the id at once, so a retry places anew. See Errors → Idempotency.

An order that has ended is not open, so GetOrder and GetOrderByClientId answer empty for it while its client_order_id still names it. Use a fresh client_order_id for every new order.

⚠️ AddLiquidity was removed from CreateOrder (2026-06-11)

The add_liquidity request variant — the range-based min_buy_price / mid_price / max_sell_price / remove_on_fill form — no longer exists. OrderVariant now creates only limit_order, market_order, or swap_order.

Provide maker / passive liquidity by placing limit orders (one or more LimitOrders at your chosen prices) — a resting limit order is a maker order and pays maker fees when filled. The LiquidityOrder message and ORDER_TYPE_LIQUIDITY still exist as the persisted / returned form (PairOrder.liquidity_order) for positions created that way, so you can still read, hold, and cancel pre-existing liquidity orders — you just can't create new ones through this call.

OrderVariant — the three variants

Exactly one variant must be set. To settle a leg on-chain instead of through a channel, see On-chain settlement below.

LimitOrder — fixed price, take it or wait (and the maker / liquidity path)

FieldTypeDescription
base / quoteOrderbookCurrencyPair
sideOrderSideORDER_SIDE_BUY or ORDER_SIDE_SELL
priceDecimalStringQuote per base
amountOrderAmountBase or quote denomination
time_in_forceTimeInForceHow the order meets the book; unset is GTC. See Time in force.

MarketOrder — fill at the best available price

FieldTypeDescription
base / quoteOrderbookCurrencyPair
amountOrderAmountBase or quote denomination
sideOrderSideORDER_SIDE_BUY or ORDER_SIDE_SELL
limit_priceDecimalString (optional)The worst price it takes at — a buy's highest, a sell's lowest — on the market's price grid. Unset takes at any price. When nothing is on offer within it, the order is refused with nothing_to_take. A capped market order locks the most it can send at that price rather than at its estimate.

SwapOrder — multi-hop cross-currency swap

FieldTypeDescription
from_currencyOrderbookCurrencySource currency
currency_pathOrderbookCurrency[]Intermediate hop currencies (may be empty)
to_currencyOrderbookCurrencyDestination currency
amountSwapAmount{ from: { amount } } or { to: { amount } }

Time in force

Upgrade your node before you rely on a new order term. Over gRPC, a node that predates a field drops it without a word — protobuf skips fields it does not know — so a newer client talking to an older node places the order without it: a node that predates time_in_force places an IOC as a plain limit order, one that predates limit_price places a capped market order uncapped. A node that knows the field but not a value in it refuses the order (INVALID_ARGUMENT). JSON-RPC refuses fields it does not know.

A limit order's time_in_force says how it meets the book, and is signed into it. Market and swap orders have none: they take what they can at once, and the rest is dropped (a market order's limit_price bounds what it takes at). Leave it unset for GTC — an explicit value is refused by a hub that predates the field, and unset and GTC are different orders to a resend under the same client_order_id.

ValueBehaviour
TIME_IN_FORCE_UNSPECIFIED (0, default)A limit order's own behaviour: GTC
TIME_IN_FORCE_GTC (1)Good till cancelled: the order takes what it crosses, and what is left rests until it fills or is cancelled
TIME_IN_FORCE_POST_ONLY (2)Rest without taking. An order that would trade against an order on offer when it arrives is refused with post_only_would_cross (FAILED_PRECONDITION), naming the best price on offer it would have taken; nothing is created. An order the hub puts back on the book across an order on offer later — after a failed swap of either, a hub restart, or its owner shown again — is cancelled with CANCEL_REASON_POST_ONLY_WOULD_CROSS instead of taking it
TIME_IN_FORCE_IOC (3)Immediate or cancel: take what it can at its price or better, and rest nothing. One that filled something is placed at what it filled, and released reports the rest — reason immediate_or_cancel, or below_best_fill / self_trade_prevented when that stopped it first. One that filled nothing is refused with nothing_to_take (or below_best_fill / self_trade_prevented); nothing is created
TIME_IN_FORCE_FOK (4)Fill or kill: take its whole amount at its price or better at once, or nothing. When the book cannot fill it, it is refused with fill_or_kill_unfilled (or self_trade_prevented, when its rule would stop it), nothing is created, and the book is left as it was — a self-trade rule that would cancel one of your orders cancels none. What it has left that cannot rest — below the smallest fill at its price, or, for an ask sized in quote, beyond the base it locked (see Orders sized in the other unit) — is released with reason immediate_or_cancel

A post-only order pays maker fees only: it never takes. IOC and FOK orders pay taker fees only: they never rest. Both are refused with market_state on a market in MARKET_STATE_POST_ONLY. A fill of either that fails gives it nothing back: it ends once its fills settle — completed when they all did, cancelled with CANCEL_REASON_FILL_FAILED otherwise.

Which taker to use. A market order takes at any price unless you give it a limit_price; its amount is what you net when you size it in what you receive (the venue tops it up for the fee). A limit order's amount is gross — what its fills take off the book, fee included — and it never takes beyond its price: IOC for "whatever is there now", FOK for "all of it now or nothing". A limit order also locks its worst case at its price up front, so no book change between your estimate and its match can move what it pays.

OrderSized inIts amount is
Market buybasewhat you receive, net of the fee
Market buyquotewhat you pay
Market sellbasewhat you pay
Market sellquotewhat you receive, net of the fee
Limit (any time in force)base or quotegross: what its fills take, fee included

Self-trade prevention

self_trade_prevention says what the order does when it would trade against another order of yours — one of your node's, or of another key whose session with the hub carries the same identity — on every market it trades on, each hop of a swap route included. It is signed into the order. Leave it unset for no prevention — an explicit value is refused by a hub that predates the field, and unset and SELF_TRADE_PREVENTION_NONE are different orders to a resend under the same client_order_id.

Of the two orders, the newer one's rule decides: your incoming order's, or, when two of your resting orders meet because one of them came back to the book (after a failed swap, a hub restart, or its owner shown again), the one placed later.

Value of the newer orderThe newer orderThe older order
SELF_TRADE_PREVENTION_UNSPECIFIED (0, default) / SELF_TRADE_PREVENTION_NONE (1)trades with it, as with any other orderis filled
SELF_TRADE_PREVENTION_CANCEL_TAKER (2)stops there: it keeps what it filled before it, and the rest does not rest — a new limit order is placed at what it filled (released in its answer), a limit order that came back to the book is cancelled with CANCEL_REASON_SELF_TRADE_PREVENTED once its fills settle, a market or swap order leaves it unfilled. An incoming order that filled nothing is refused with self_trade_prevented, naming your order it metstays as it is
SELF_TRADE_PREVENTION_CANCEL_MAKER (3)goes on to the next orderis cancelled with CANCEL_REASON_SELF_TRADE_PREVENTED (a pending cancel if a fill of it is still settling)
SELF_TRADE_PREVENTION_CANCEL_BOTH (4)stops, as with CANCEL_TAKERis cancelled, as with CANCEL_MAKER

An order a rule cancels stays cancelled whatever becomes of the other, refused or not.

Price priority: nothing trades through an order it cannot fill

Every fill has a minimum size at its price (the market's structural minimums, see Two different minimums). A taker walks the book best price first and stops at the first order it may take but is too small for — it never skips it to fill at a worse price. What that means for each order type:

  • Market and swap orders keep what they filled before that order; the rest is dropped, as when the book runs out. One that filled nothing is refused with below_best_fill: min_fill is the smallest fill of that order, at price.
  • A new limit order whose remainder could rest at its own price but would then rest across that order does not rest across it:
    • with nothing filled it is refused with below_best_fill, as above — nothing is created;
    • after a fill it is placed at what it filled, as an order filled in full: its amount becomes what those fills take off it, in its own unit (base or quote, as you sized it); it completes when they settle, and a fill that fails without you at fault gives it that quantity back, to meet the book again. The CreateOrder answer and the OrderCreated event carry released: what was not placed, in the order's own amount, and why — below_best_fill { price, min_fill } (min_fill in the order's own unit too) or self_trade_prevented { resting_order_id }.
  • A limit order the hub puts back on the book (after a failed swap, a restart, or its owner shown again) that would rest across such an order is cancelled with CANCEL_REASON_BELOW_BEST_FILL, once any fill of it settles.

A remainder too small to rest at its own price is handled as before. Re-place larger, or at a price that does not cross price.

Orders sized in the other unit

The book holds a bid in quote and an ask in base. A bid sized in base, or an ask sized in quote, still takes in its own amount and never more than it asked: crossing better prices, a bid for 25 base buys 25 base — not what its quote at its own price would buy there — and an ask for 10 quote is paid 10 quote, selling less base. What is left rests at its price: a bid as its base; an ask as the whole lots its quote covers there, rounded down, so it is never paid more than its quote — and never sells more base than it locked, whatever its fills are paid (a restart locks it again, for what its record has left). A remainder no whole lot can take ends the order as a dust remainder does (CANCEL_REASON_HOUSEKEEPING). A fill of a bid sized in base, taking or resting, has its quote rounded down, so it never pays more than its lots are worth at the fill's price, nor more than it locked; a failed fill gives such an order back in its own amount. Every other fill's quote — an ask sized in quote's too — is rounded to the quote grid half to even, so a fill can be up to half a quote unit off its price. Such an ask is never paid more than its quote; as it never sells more base than it locked, fills paid under their lots' worth can leave it short of its quote, and what it has left then goes as a dust remainder does. A bid sized in quote rests the whole lots what it holds covers at its price, to the nearest lot, and taken in full pays what it holds: that fill can be up to half a lot off its price.

On-chain settlement

Added 2026-06-11 / 2026-06-15.

By default every swap leg settles through a payment channel. A taker order (market_order / swap_order) can opt individual legs onto on-chain HTLC settlement instead, so you can trade a currency you hold on-chain without first opening a channel for it. This is signed into the order as an OrderSettlement (from currency.proto):

FieldTypeDescription
sendingLegSettlementHow you send to the hub: CHANNEL (0, default), ONCHAIN (1), or CHANNEL_OR_ONCHAIN (2, orderbook picks — prefers channel)
receivingLegSettlementHow you receive from the hub (same values)
route_filteruint32 (optional)Taker-only route-wide method bitmask (bit 0 = Channel, bit 1 = On-chain). Absent = no constraint. Must be absent on a resting maker order.

Absent settlement ⇒ channel on both legs (the previous behavior). On-chain is valid only for taker orders — resting maker orders are channel-only. The on-chain refund/claim addresses are not declared here; the node supplies them later at the orderbook's PrepareSwap, and the per-hop details surface on the matched-order route (SwapHop.sending_onchain / receiving_onchain) and as simple-swap milestones. See the channel-vs-on-chain settlement model for the full picture.

Example — market sell 0.0005 BTC for USDC:

import { CreateOrderRequest, OrderVariant, OrderSide, OrderAmount } from './proto/orderbook_pb'

const market = new OrderVariant.MarketOrder()
market.setBase(SIGNET_BTC)
market.setQuote(SEPOLIA_USDC)
market.setSide(OrderSide.ORDER_SIDE_SELL)
const amt = new OrderAmount()
amt.setBase({ amount: { value: '0.0005' } })
market.setAmount(amt)

const variant = new OrderVariant()
variant.setMarketOrder(market)

const req = new CreateOrderRequest()
req.setOrderVariant(variant)
req.setClientOrderId('bot:bid-2026-06-08:0001')   // idempotent retry

const resp = await orderbook.createOrder(req, {})
console.log('order id:', resp.getOrderId())

Cancel Order

Method: CancelOrder

Params: { order_id: string }

Response: { removed: bool, pending: bool } — removed is true when the cancel was accepted: the order is off the book and matches no further. With pending: false it is gone and its liquidity released.

removed: false is a successful call, not an error. It means there was nothing to cancel: the order had already filled, already been cancelled, or never existed. Treat it as "the order is not on the book", which is the state you were asking for either way.

pending: true means a fill against the order is still settling. Added 2026-09-20. The order stays listed — GetOrder / GetAllOrders return it with pending_cancel: true — and still reserves the in-flight portion until that fill settles, at which point OrderCanceled (or OrderCompleted, if the fill consumed it) arrives on the private stream. The rest of its liquidity is released at once. Nothing you do speeds this up: a second CancelOrder is accepted and changes nothing. Treat it as cancelled and wait for the terminal update before dropping it from your mirror.


Cancel All Orders

Cancel every open order belonging to the caller.

Method: CancelAllOrders

Params: none.

Response: empty.


Get Order

Method: GetOrder

Params: { order_id: string }

Response: { order?: Order }. Returns empty when the order is not open: an order that has ended is not returned.

Order — the persisted form

The Order message is a oneof over PairOrder (limit / market / liquidity) and SwapOrder (cross-currency), plus the client_order_id you supplied and whether a cancel is waiting on a settling fill:

Order {
  oneof order {
    PairOrder pair_order = 1;     // limit_order | market_order | liquidity_order
    SwapOrder swap_order = 2;
  }
  optional string client_order_id = 3;
  bool pending_cancel = 4;        // added 2026-09-20, see Cancel Order; only a limit or liquidity order is ever pending a cancel
}
  • PairOrder is a oneof of LiquidityOrder (passive provision), LimitOrder (fixed price), or MarketOrder (best-available).
  • SwapOrder carries from_currency / currency_path[] / to_currency and detailed fill / fee accounting.
  • Each order type's view carries its own terms, as placed: a LimitOrder its time_in_force (7) and self_trade_prevention (8), a MarketOrder its self_trade_prevention (8) and limit_price (9), a SwapOrder its self_trade_prevention (11). A value your client does not know reads as the unspecified one.

Field-level shapes are in orderbook.proto — they're long, exhaustive, and best read there rather than mirrored here.

⚠️ LimitOrder (and friends) — same name, different shape on request vs response

The proto reuses the names LimitOrder / MarketOrder / SwapOrder for two distinct messages:

  • The request form is OrderVariant.LimitOrder (nested under OrderVariant) — it carries the order spec you submit to CreateOrder / EstimateOrder.
  • The response / persisted form is the top-level LimitOrder — it carries the order as it lives in the orderbook, with created_at, remaining_amount, and a variant: OrderSideVariant field instead of a flat OrderSide side.

The two have non-trivially different fields. In particular, the response form's variant is itself a oneof (OrderSideVariant) of Buy { bought_base_amount, sold_quote_amount, paid_base_fee } or Sell { sold_base_amount, bought_quote_amount, paid_quote_fee }. A response LimitOrder does not carry a flat side: OrderSide field — WhichOneof('side') on variant is the discriminator, and the fill / fee accounting fields differ per side.

In practice:

  • When writing an order, you reference the nested OrderVariant.LimitOrder (see Create Order → OrderVariant).
  • When reading an order back, you reference the top-level LimitOrder (this section).
  • Same applies to MarketOrder and SwapOrder.

Some clients (e.g. when generated Python imports both into one namespace) will alias-collide here; rename or scope the imports to keep the two distinct.


Get Order By Client Id

Added 2026-05-03. Pair with idempotent order creation.

Method: GetOrderByClientId

Params: { client_order_id: string }

Response: { order?: Order }. Empty when no open order matches — including an id that names an order that has ended, which it keeps naming at CreateOrder for the retention period.


Get Own Orders

The caller's open orders for one pair.

Method: GetOwnOrders

Params: { base: OrderbookCurrency, quote: OrderbookCurrency }

Response: { orders: map<string, Order> } — keyed by order_id.


Get All Own Orders

The caller's open orders across every pair.

Method: GetAllOwnOrders

Params: none.

Response: { orders: map<string, Order> } — keyed by order_id.


Get Trade History

Public trade history for one market — anyone trading the pair, paginated.

Method: GetTradeHistory

Params: { base: OrderbookCurrency, quote: OrderbookCurrency, pagination: PaginationRequest }

Response: { trade_history: Trade[], pagination: PaginationResponse }, newest first.

The field is trade_history, not trades — only the caller's own trade calls (GetPairMarketTrades, GetPairSwapTrades) use trades.

Trade:

FieldTypeDescription
taker_order_idstringThe order that crossed the book
base_amountDecimalStringBase amount of the fill
quote_amountDecimalStringQuote amount of the fill
priceDecimalStringPre-fee execution price
final_priceDecimalStringAfter-fee effective price
timestampTimestampFill time
maker_order_sideOrderSideThe maker side of the trade

Get Pair Market Trades

The caller's market trades for one pair.

Method: GetPairMarketTrades

Params: { base: OrderbookCurrency, quote: OrderbookCurrency, pagination: PaginationRequest }

Response: { trades: ClientMarketTrade[], pagination: PaginationResponse }.

ClientMarketTrade:

FieldTypeDescription
swap_idstringThe DEX swap ID backing the fill
order_idstringCaller's order that produced the fill
base_amount, quote_amountDecimalStringFilled volumes
base_feeDecimalStringThe fill's fee on the base side, charged to whoever received base
quote_feeDecimalStringThe fill's fee on the quote side, charged to whoever received quote
price, final_priceDecimalStringPre- and after-fee prices
timestampTimestampFill time
order_sideOrderSideSide of the caller's order
order_typeOrderTypeORDER_TYPE_LIMIT / ORDER_TYPE_MARKET / ORDER_TYPE_LIQUIDITY
role (2026-09-19)TradeRoleTRADE_ROLE_MAKER or TRADE_ROLE_TAKER — which side of the fill the caller was on

Only one of the two fee columns is yours. A fill has two sides and both are priced, so every record carries both figures: the base fee belongs to whoever received base, the quote fee to whoever received quote. On a BUY you received base, so base_fee is yours and quote_fee is your counterparty's — it moved no balance of yours. On a SELL it is the other way round. Summing both columns double-counts a fee you never paid.

The sign is not the role. Positive means the fee was taken, negative that it was credited back — nothing more. The operator sets a market's maker and taker rates independently, and either may be negative, zero or positive; the only constraint is that the maker rate never exceeds the taker rate. role is what says which of the two your fee was struck at — the one on the side you received. The other column is your counterparty's, struck at the opposite role's rate. Without role, neither may be inferred.

TradeRole: TRADE_ROLE_MAKER (1) — your resting order was filled. TRADE_ROLE_TAKER (2) — your order crossed the book. TRADE_ROLE_UNSPECIFIED (0) means the role is not being reported — a hub predating the field, or one that cannot classify the stored fill — and the field is omitted from JSON entirely in that case.


Get All Market Trades

The caller's market trades across every pair. Same response shape as GetPairMarketTrades.

Method: GetAllMarketTrades

Params: { pagination: PaginationRequest }

Response: { trades: ClientMarketTrade[], pagination: PaginationResponse }.

This is the complete fill history, in both roles — reconcile balances against it. One record per fill, whether you took it or your resting order made it. Its sibling GetAllSwapTrades records takers only, so a history built from that feed alone is short by every fill you made.


Get Pair Swap Trades

The caller's multi-currency swap trades for one (from, to) pair.

Method: GetPairSwapTrades

Params: { from_currency: OrderbookCurrency, to_currency: OrderbookCurrency, pagination: PaginationRequest }

Response: { trades: ClientSwapTrade[], pagination: PaginationResponse }.

ClientSwapTrade:

FieldTypeDescription
swap_idstringDEX swap ID
order_idstringCaller's order that produced the swap
from_currency_amountDecimalStringAmount sent in the source currency
to_currency_amountDecimalStringThe final hop's output before its own fee — see below
to_currency_feeDecimalStringFee taken on the final hop, in the destination currency
timestampTimestampFill time

You received to_currency_amount − to_currency_fee, not to_currency_amount. The final hop's fee is still to be subtracted. Every earlier hop's fee already is: each hop hands the next its net proceeds, so a route's intermediate costs are baked into this figure and are not subtracted again.

One more deduction applies when the final hop settles on-chain and the hub sponsors the claim: a claim-gas reserve covering what the hub spends claiming on your behalf. It is not part of to_currency_fee — it rides the matched order's route, folded into the last SwapHop.receiving_fee — so an on-chain receive lands that much lower again.

A routed swap therefore pays a fee at every hop, and only the last one appears here. The earlier ones are charged in their own hops' currencies and itemised, one per hop, by the ClientMarketTrade records sharing this swap_id. Totalling to_currency_fee across a route understates what you paid.


Get All Swap Trades

The caller's swap trades across every (from, to) pair. Same shape as GetPairSwapTrades.

Method: GetAllSwapTrades

Params: { pagination: PaginationRequest }

Taker only, and one record per swap — not per fill. A swap is recorded against the caller whose order crossed the book. A fill your resting order made has no record here and never will; it appears in GetAllMarketTrades with role = TRADE_ROLE_MAKER. The two feeds look like mirrors for a caller that only ever takes, which is exactly how the difference gets missed: a limit order that crosses on arrival and rests with its remainder produces taker fills in both feeds and a later maker fill in one. Build accounting from GetAllMarketTrades; use this feed for the taker's cross-currency view of a route.


Subscribe Market Events

Public market data for one pair — orderbook deltas, trades, daily stats, candlesticks. Server-streaming.

Method: SubscribeMarketEvents

Params: { base: OrderbookCurrency, quote: OrderbookCurrency }

Stream of MarketEvent:

MarketEvent {
  oneof update {
    bool is_synced = 1;
    OrderbookUpdate orderbook_update = 2;   // {updated_orders, removed_orders}
    Trade trade_update = 3;                 // public Trade
    MarketDailyStats daily_stats_update = 4;
    CandlestickUpdate candlestick_update = 5;
  }
}

Subscribe before you act. If you place an order before the stream attaches, you can miss the fill notification. See Streaming guide.

Read the market again on is_synced: true. is_synced: false then true arrives whenever the feed re-syncs, and also when the node's relay of the market fell so far behind the feed that events were dropped: the market may have changed meanwhile, and the stream carries on only from then.

Every stream opens with the market's sync state: is_synced: true when the market is synced, is_synced: false while it is not — the market's own is_synced: true follows once it is done. Read the market's current state once the stream is open — GetOrderbook, GetMarketDailyStats — and apply the stream's events from then on.

Any number of clients can subscribe to the same pair: each stream gets every event of the market, whoever else is watching it, whichever way round it names the pair. A pair not initialised yet is not an error: the stream stays open, and opens once the market is — by any client's InitMarket, or by the node itself.

Available over gRPC as a server-stream, or over JSON-RPC as a WebSocket subscription (orderbook_subscribeMarketEvents). A one-shot HTTP POST cannot carry it.


Subscribe DEX Events

Your personal account activity — balance updates, order lifecycle, fills, ongoing swap progress. Server-streaming.

Method: SubscribeDexEvents

Params: none.

Stream of DexEvent:

DexEvent {
  google.protobuf.Timestamp timestamp = 1;
  oneof update {
    bool is_synced = 2;
    BalanceUpdate balance_update = 3;
    OrderUpdate order_update = 4;     // OrderCreated / OrderUpdated / OrderCompleted / OrderCanceled
    MatchedOrder order_matched = 5;
    SwapUpdate swap_update = 6;
    MarketTradeUpdate market_trade_update = 7;
    SwapTradeUpdate swap_trade_update = 8;
    FeeDiscountStanding fee_discount_update = 9;   // 2026-09-03
  }
}

MarketTradeUpdate carries a ClientMarketTrade (same shape as GetPairMarketTrades); SwapTradeUpdate carries a ClientSwapTrade. Use these to populate a live "my trades" view.

fee_discount_update (2026-09-03) carries the full FeeDiscountStanding — your fee-discount standing changed, in either role. The hub opens this stream with your standing and pushes the whole standing again whenever it moves, so silence means unchanged and the last message received is the one to hold. Nothing needs to poll GetFeeDiscount; a dropped stream heals by reconnecting into a fresh opening standing. Refresh any displayed rates — and any cached GetMarketFeeRates — when one arrives.

Same two transports as SubscribeMarketEvents — gRPC server-stream, or the orderbook_subscribeDexEvents WebSocket subscription.


Common pitfalls

SymptomCauseFix
Invalid sending currency: Conversion errorUsed assetId: "BTC" or mixed-case ERC20:0xAbC…Use the canonical forms — see asset_id format
Order placed, no fill eventsSubscribed to SubscribeMarketEvents (or SubscribeDexEvents) after placing the orderSubscribe at startup, then act
Two orders placed after a DEADLINE_EXCEEDED retryCreateOrder is not idempotent without client_order_idAlways set client_order_id on retry-prone paths
FAILED_PRECONDITION with post_only_would_crossA post-only order (or any limit order on a MARKET_STATE_POST_ONLY market) would have taken an order on offerRe-price it so it does not cross — below best for a bid, above it for an ask
FAILED_PRECONDITION with self_trade_preventedThe order's self_trade_prevention stopped it at your own resting order resting_order_id before it filled anythingCancel or move that order, or place with SELF_TRADE_PREVENTION_CANCEL_MAKER
FAILED_PRECONDITION with below_best_fillThe best order the order can take has a smallest fill (min_fill, at price) larger than what the order hadRe-place larger, or at a price that does not cross price — see Price priority
A limit order placed smaller than you asked, released in the answerIt filled part at once and the rest would have rested across an order it is too small for, or its self-trade rule stopped itRead released; place the rest again if you still want it
ALREADY_EXISTS with client_order_id_taken on a new orderThe client_order_id still names an earlier order — live, or ended within the retention periodUse a fresh client_order_id for every new order; resend the earlier order unchanged to get its answer back
Streaming call returns -32603 Internal error via JSON-RPCSubscribed over a one-shot HTTP POST, which cannot carry a streamOpen a WebSocket to the same URL and subscribe there — see Subscriptions over WebSocket — or use gRPC
Bot decodes SwapRole integer 0 as takerSwapRole was renumbered on 2026-05-03 (TAKER moved 0 → 3)Re-map persisted integers; or compare against the string form
Fees charged don't match what you computedDerived your fee from MarketInfo's list rates, or applied listed × (1 − discount) to a maker rebateCall GetMarketFeeRates. The formula is listed − discount × |listed| — signed, so a rebate deepens
A maker rebate came out smaller after earning a tierSame sign bug, in the arithmeticSee the arithmetic
Bot reads a fill amount as zero or as the wrong currencyRead OrderAmount.base.amount (or .quote.amount) without checking which variant is setOrderAmount echoes the denomination you placed the order in — always discriminate on the oneof variant, not on side. Confusing the per-order remaining/unmatched/failed_amount with the public LiquidityPosition.amount (which is base-on-sell / quote-on-buy) leads to the same bug. See the OrderAmount callout
Buy-side LimitOrder fields look empty in the responseMixed up the request-form OrderVariant.LimitOrder (has flat side) with the response-form top-level LimitOrder (has variant: OrderSideVariant oneof)See LimitOrder request vs response — read variant.buy / variant.sell on responses

See also


Copyright © 2025