Orderbook API
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
- Init Market
- Get Initialized Markets
- Get Markets Info
- Get Market Info
- Get Market Fee Rates — this node's rates, discounts applied
- Get Currencies — the operator's asset allowlist
- Get Orderbook Balances
- Get Orderbook
- Get Market Daily Stats
Venue economics
- Get Fee Discount Programs — the ladders, as configured
- Get Fee Discount — your standing on them
- Get Sub-Economic Policy — what force-closing costs, and your limit on small swaps
Orders
- Estimate Order
- Create Order
- Cancel Order
- Cancel All Orders
- Get Order
- Get Order By Client Id
- Get Own Orders
- Get All Own Orders
Trades (public + per-account)
- Get Trade History — public trades for one pair
- Get Pair Market Trades — your trades on one pair
- Get All Market Trades — your trades across every pair
- Get Pair Swap Trades — your swap trades for one currency pair
- Get All Swap Trades — your swap trades across every pair
Streams
- Subscribe Market Events — public orderbook + trade stream for one pair
- Subscribe DEX Events — your balance updates, order lifecycle, fills, swap progress
Shared types
OrderbookCurrency
Identifies an asset for orderbook RPCs (note: distinct from Network — flat shape).
| Field | Type | Notes |
|---|---|---|
protocol | Protocol enum | PROTOCOL_BITCOIN or PROTOCOL_EVM |
network_id | string | Magic bytes (Bitcoin) or decimal chain ID (EVM) |
asset_id | string | See asset_id format below |
asset_id format
Verified live against a running Hydra App; do not use the ticker.
| Asset | Form |
|---|---|
| Native asset (BTC, ETH, …) | Zero-padded 32-byte hex: 0x0000000000000000000000000000000000000000000000000000000000000000 |
| ERC-20 token | erc20:<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:
| Variant | Payload | Meaning |
|---|---|---|
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.
⚠️OrderAmountechoes the denomination you placed the order in
OrderAmountis aoneofprecisely so it can carry either denomination. The side of an order (buy/sell) does not determine it. When you create aLimitOrderorMarketOrder, you choosebaseorquotefreely — a buy can be sized in base or quote, and so can a sell. On response orders,remaining_amount,unmatched_amount, andfailed_amountcome 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
oneofvariant actually set — never on the side:
- Python:
WhichOneof('amount')(hasattris useless ononeofs — 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.amountin the public orderbook (GetOrderbook). That field is a single scalar reporting offered liquidity, so it genuinely isbase-on-sell /quote-on-buy. Theremaining_amount/unmatched_amount/failed_amounton 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:
| Enum | Values |
|---|---|
OrderSide | ORDER_SIDE_UNSPECIFIED (0), ORDER_SIDE_BUY (1), ORDER_SIDE_SELL (2) |
OrderType | ORDER_TYPE_UNSPECIFIED (0), ORDER_TYPE_LIMIT (1), ORDER_TYPE_LIQUIDITY (2), ORDER_TYPE_MARKET (3), ORDER_TYPE_SWAP (4) |
AmountSide | AMOUNT_SIDE_UNSPECIFIED (0), AMOUNT_SIDE_BASE (1), AMOUNT_SIDE_QUOTE (2) |
TimeInForce | TIME_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) |
SelfTradePrevention | SELF_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) |
SwapRole | SWAP_ROLE_UNSPECIFIED (0), SWAP_ROLE_LAST_MAKER (1), SWAP_ROLE_INTERMEDIATE_MAKER (2), SWAP_ROLE_TAKER (3) |
SwapStatus | SWAP_STATUS_UNSPECIFIED (0) → SWAP_STATUS_SWAP_FAILED (9). See proto for the full list. |
CandlestickInterval | CANDLESTICK_INTERVAL_UNSPECIFIED (0), ..._ONE_MINUTE (1), ..._THREE_MINUTES (2), … ..._ONE_MONTH (15) |
SWAP_ROLE_TAKERwas 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
| Param | Type | Description |
|---|---|---|
first_currency | OrderbookCurrency | One side of the pair |
other_currency | OrderbookCurrency | The 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:
| Field | Type | Description |
|---|---|---|
base | CurrencyInfo | Base currency + on-chain decimals |
quote | CurrencyInfo | Quote currency + on-chain decimals |
taker_base_fee | DecimalString | List taker fee ratio on the base side — see the note below |
taker_quote_fee | DecimalString | List taker fee ratio on the quote side |
maker_base_fee | DecimalString | List maker fee ratio on the base side. Negative = a rebate paid to the maker |
maker_quote_fee | DecimalString | List maker fee ratio on the quote side |
base_precision | uint32 | Decimal places of precision for base amounts |
quote_precision | uint32 | Decimal places of precision for quote amounts |
min_base_amount | DecimalString | Structural minimum, base side — see below |
min_quote_amount | DecimalString | Structural minimum, quote side |
min_place_base_amount | DecimalString? | (2026-08-15) Advisory placement-floor snapshot, base side — see below |
min_place_quote_amount | DecimalString? | (2026-08-15) Advisory placement-floor snapshot, quote side |
order_min_notional_usd | DecimalString? | (2026-08-15) The pair's USD placement floor. Absent when disabled. |
max_taker_discount | DecimalString? | (2026-09-04) Deepest discount a taker's fee on this market is struck at. 0 = takers pay list here |
max_maker_discount | DecimalString? | (2026-09-04) Deepest discount a maker's rate on this market is struck at. 0 = makers trade at list here |
state | MarketState | (2026-09-16) Whether this market still takes orders — see below |
These are list rates, not yours
MarketInfoadvertises 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 — callGetMarketFeeRates, 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 naivelisted × (1 − discount)gets the rebate case backwards.The three
optionalfields 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_ONLYmarket at all; the operator puts no market in that state until clients are updated. Read a state you do not know asFROZEN.
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_infoon the market stream and replace your copy when it arrives, rather than learning about it from amarket_staterejection. BothGetMarketsInfoandGetMarketInfostamp it; aMarketInfoembedded 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 refetchGetMarketInfo— see Errors → Placement rejections. Sizing purely off a cachedmin_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:
| Field | Type | Description |
|---|---|---|
taker_base_fee | DecimalString | Charged on the base this node receives when it takes liquidity |
taker_quote_fee | DecimalString | Charged on the quote this node receives when it takes liquidity |
maker_base_fee | DecimalString | Charged on the base this node receives when one of its resting orders fills. Negative = a rebate paid to this node |
maker_quote_fee | DecimalString | Charged on the quote this node receives when one of its resting orders fills |
taker_discount | DecimalString | The 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_discount | DecimalString | The 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_updateevent 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:
| Field | Type | Description |
|---|---|---|
currency | OrderbookCurrency | The currency itself |
ticker | string? | 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 |
class | CurrencyClass | CURRENCY_CLASS_STANDARD (1) or CURRENCY_CLASS_STABLECOIN (2) |
state | CurrencyState | ..._ENABLED (1), ..._CANCEL_ONLY (2), ..._FROZEN (3), ..._POST_ONLY (4) |
min_notional_usd | DecimalString? | This currency's own USD placement floor. Policy input, not the floor an order is checked against |
min_native_amount | DecimalString? | 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
HDNcounts your HDN wherever it lives.
Lifecycle states:
| State | New orders | Cancels & in-flight settlement | Swap routing |
|---|---|---|---|
ENABLED | ✅ | ✅ | ✅ |
CANCEL_ONLY | ❌ | ✅ | ✅ |
FROZEN | ❌ | ✅ | ❌ |
POST_ONLY | Limit 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
0would be indistinguishable from an absent one — treatCURRENCY_CLASS_UNSPECIFIED/CURRENCY_STATE_UNSPECIFIEDas a conversion error, not a default. A currency with notickeris alwaysCANCEL_ONLYand carriesSTANDARD(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:
| RPC | Answers |
|---|---|
GetFeeDiscountPrograms | What ladders exist, as configured |
GetFeeDiscount | Where you stand on them |
GetMarketFeeRates | What one market costs you, both roles, already priced |
The arithmeticA 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:
| Field | Type | Description |
|---|---|---|
volume | VolumeDiscountProgram? | The volume ladder |
holdings | HoldingDiscountProgram[] | One entry per holding ladder |
max_taker_discount | DecimalString? | Operator's default ceiling on a market's taker discount. Absent = only the ladders' own maxima bound it |
max_maker_discount | DecimalString? | 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:
| Variant | Meaning |
|---|---|
no_program | The operator runs no discount program: every fill is list price |
excluded | This node is excluded: list price in both roles, and its settled volume earns nothing |
active | ActiveFeeDiscount — your standing on the programs that run |
ActiveFeeDiscount:
| Field | Type | Description |
|---|---|---|
taker_discount | DecimalString | The 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_discount | DecimalString | The same for your maker fills |
volume | VolumeDiscountStanding? | Absent when the operator runs no volume ladder |
holdings | HoldingDiscountStanding[] | One per configured holding ladder; empty when none run |
taker_discount/maker_discounthere are figures to show, not to price with. Every market bounds them by its own maximum. To price an order, callGetMarketFeeRates— 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. CallGetFeeDiscountto 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:
| Field | Type | Description |
|---|---|---|
by_network | SubEconomicNetworkPolicy[] | Per-network pricing. A network with no entry has the mechanism off — nothing there is ever sub-economic |
client_force_close_allowance_usd | DecimalString | How much force-closure cost a client with no settled volume may have at risk, across every network at once |
earned_usd_per_usd_settled | DecimalString | Extra allowance earned per USD settled inside the window. Zero means the ladder is off |
earned_cap_usd | DecimalString | Ceiling on the earned part |
earned_window_days | uint32 | Rolling window the settled volume is measured over |
SubEconomicNetworkPolicy:
| Field | Type | Description |
|---|---|---|
network | Network | The network this pricing applies to |
asset_channel_close_usd | DecimalString | What force-closing one asset channel costs the hub here, carrying no pending payments |
htlc_resolution_usd | DecimalString | What resolving one pending HTLC inside that closure costs on top. May be zero |
peer_htlc_cap | uint32 | Most concurrent sub-economic HTLC legs one peer may hold here |
peer_htlc_capis not part of the cost arithmetic. What those HTLCs cost to resolve is already priced byhtlc_resolution_usdand 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_limitrejection — 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.
| Field | Type | Description |
|---|---|---|
sending | DecimalString | The 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 |
receiving | DecimalString | The 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_receiving | DecimalString | Arriving from a deposit or channel open whose funding has not confirmed yet; spendable once it does |
unavailable_sending / unavailable_receiving | DecimalString | Owned 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_receiving | DecimalString | Held by your open orders and the swaps they matched into |
ineligible_sending / ineligible_receiving | DecimalString | Free 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:
| Field | Type | Description |
|---|---|---|
info | MarketInfo | Market configuration |
orders | map<string, LiquidityPosition> | Active liquidity positions, keyed by order_id |
LiquidityPosition:
| Field | Type | Description |
|---|---|---|
client_pubkey | bytes | Ed25519 public key of the position owner |
side | OrderSide | Buy or sell |
price | DecimalString | Price (quote per base) |
amount | DecimalString | Offered — base amount on sell orders, quote on buy orders |
matched_amount | DecimalString | Amount already matched |
pending_cancel | bool | True 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_updatewhen 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 onis_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
| Param | Type | Required | Description |
|---|---|---|---|
order_variant | OrderVariant | YES | Exactly one of LimitOrder / MarketOrder / SwapOrder |
client_order_id | string | NO | Added 2026-05-03. Your own identifier, max 64 chars. See idempotency note below. |
settlement | OrderSettlement | NO | Per-leg channel vs on-chain settlement. See On-chain settlement. |
self_trade_prevention | SelfTradePrevention | NO | What 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 }.
releasedis 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, andreleasedsays what was not placed and why. See Price priority. The order'sOrderCreatedevent carries it too.- A request sent again under the
client_order_idof its order gets the answer that order's placement gave,releasedincluded, 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 noclient_order_id.
Idempotent order creation. A
client_order_idnames 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 sameorder_variant,settlementandself_trade_prevention, every amount and price written the same way (1.0and1.00differ) — and you get the answer that order's placement gave, live or ended — itsorder_id, and what it released — with nothing placed, so a network blip orDEADLINE_EXCEEDEDretry never double-places. A different order under it is refused withALREADY_EXISTSand theclient_order_id_takenprefix, 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
GetOrderandGetOrderByClientIdanswer empty for it while itsclient_order_idstill names it. Use a freshclient_order_idfor every new order.
⚠️AddLiquiditywas removed fromCreateOrder(2026-06-11)The
add_liquidityrequest variant — the range-basedmin_buy_price/mid_price/max_sell_price/remove_on_fillform — no longer exists.OrderVariantnow creates onlylimit_order,market_order, orswap_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. TheLiquidityOrdermessage andORDER_TYPE_LIQUIDITYstill 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)
| Field | Type | Description |
|---|---|---|
base / quote | OrderbookCurrency | Pair |
side | OrderSide | ORDER_SIDE_BUY or ORDER_SIDE_SELL |
price | DecimalString | Quote per base |
amount | OrderAmount | Base or quote denomination |
time_in_force | TimeInForce | How the order meets the book; unset is GTC. See Time in force. |
MarketOrder — fill at the best available price
| Field | Type | Description |
|---|---|---|
base / quote | OrderbookCurrency | Pair |
amount | OrderAmount | Base or quote denomination |
side | OrderSide | ORDER_SIDE_BUY or ORDER_SIDE_SELL |
limit_price | DecimalString (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
| Field | Type | Description |
|---|---|---|
from_currency | OrderbookCurrency | Source currency |
currency_path | OrderbookCurrency[] | Intermediate hop currencies (may be empty) |
to_currency | OrderbookCurrency | Destination currency |
amount | SwapAmount | { 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_forceplaces anIOCas a plain limit order, one that predateslimit_priceplaces 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.
| Value | Behaviour |
|---|---|
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.
| Order | Sized in | Its amount is |
|---|---|---|
| Market buy | base | what you receive, net of the fee |
| Market buy | quote | what you pay |
| Market sell | base | what you pay |
| Market sell | quote | what you receive, net of the fee |
| Limit (any time in force) | base or quote | gross: 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 order | The newer order | The older order |
|---|---|---|
SELF_TRADE_PREVENTION_UNSPECIFIED (0, default) / SELF_TRADE_PREVENTION_NONE (1) | trades with it, as with any other order | is 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 met | stays as it is |
SELF_TRADE_PREVENTION_CANCEL_MAKER (3) | goes on to the next order | is 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_TAKER | is 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_fillis the smallest fill of that order, atprice. - 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
CreateOrderanswer and theOrderCreatedevent carryreleased: what was not placed, in the order's own amount, and why —below_best_fill { price, min_fill }(min_fillin the order's own unit too) orself_trade_prevented { resting_order_id }.
- with nothing filled it is refused with
- 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):
| Field | Type | Description |
|---|---|---|
sending | LegSettlement | How you send to the hub: CHANNEL (0, default), ONCHAIN (1), or CHANNEL_OR_ONCHAIN (2, orderbook picks — prefers channel) |
receiving | LegSettlement | How you receive from the hub (same values) |
route_filter | uint32 (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: falseis 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: truemeans a fill against the order is still settling. Added 2026-09-20. The order stays listed —GetOrder/GetAllOrdersreturn it withpending_cancel: true— and still reserves the in-flight portion until that fill settles, at which pointOrderCanceled(orOrderCompleted, 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 secondCancelOrderis 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
}
PairOrderis aoneofofLiquidityOrder(passive provision),LimitOrder(fixed price), orMarketOrder(best-available).SwapOrdercarriesfrom_currency/currency_path[]/to_currencyand detailed fill / fee accounting.- Each order type's view carries its own terms, as placed: a
LimitOrderitstime_in_force(7) andself_trade_prevention(8), aMarketOrderitsself_trade_prevention(8) andlimit_price(9), aSwapOrderitsself_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 responseThe proto reuses the names
LimitOrder/MarketOrder/SwapOrderfor two distinct messages:
- The request form is
OrderVariant.LimitOrder(nested underOrderVariant) — it carries the order spec you submit toCreateOrder/EstimateOrder.- The response / persisted form is the top-level
LimitOrder— it carries the order as it lives in the orderbook, withcreated_at,remaining_amount, and avariant: OrderSideVariantfield instead of a flatOrderSide side.The two have non-trivially different fields. In particular, the response form's
variantis itself aoneof(OrderSideVariant) ofBuy { bought_base_amount, sold_quote_amount, paid_base_fee }orSell { sold_base_amount, bought_quote_amount, paid_quote_fee }. A responseLimitOrderdoes not carry a flatside: OrderSidefield —WhichOneof('side')onvariantis 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
MarketOrderandSwapOrder.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, nottrades— only the caller's own trade calls (GetPairMarketTrades,GetPairSwapTrades) usetrades.
Trade:
| Field | Type | Description |
|---|---|---|
taker_order_id | string | The order that crossed the book |
base_amount | DecimalString | Base amount of the fill |
quote_amount | DecimalString | Quote amount of the fill |
price | DecimalString | Pre-fee execution price |
final_price | DecimalString | After-fee effective price |
timestamp | Timestamp | Fill time |
maker_order_side | OrderSide | The 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:
| Field | Type | Description |
|---|---|---|
swap_id | string | The DEX swap ID backing the fill |
order_id | string | Caller's order that produced the fill |
base_amount, quote_amount | DecimalString | Filled volumes |
base_fee | DecimalString | The fill's fee on the base side, charged to whoever received base |
quote_fee | DecimalString | The fill's fee on the quote side, charged to whoever received quote |
price, final_price | DecimalString | Pre- and after-fee prices |
timestamp | Timestamp | Fill time |
order_side | OrderSide | Side of the caller's order |
order_type | OrderType | ORDER_TYPE_LIMIT / ORDER_TYPE_MARKET / ORDER_TYPE_LIQUIDITY |
role (2026-09-19) | TradeRole | TRADE_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
BUYyou received base, sobase_feeis yours andquote_feeis your counterparty's — it moved no balance of yours. On aSELLit 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.
roleis 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. Withoutrole, 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
GetAllSwapTradesrecords 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:
| Field | Type | Description |
|---|---|---|
swap_id | string | DEX swap ID |
order_id | string | Caller's order that produced the swap |
from_currency_amount | DecimalString | Amount sent in the source currency |
to_currency_amount | DecimalString | The final hop's output before its own fee — see below |
to_currency_fee | DecimalString | Fee taken on the final hop, in the destination currency |
timestamp | Timestamp | Fill time |
You received
to_currency_amount − to_currency_fee, notto_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 lastSwapHop.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
ClientMarketTraderecords sharing thisswap_id. Totallingto_currency_feeacross 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
GetAllMarketTradeswithrole = 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 fromGetAllMarketTrades; 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: falsethentruearrives 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: truewhen the market is synced,is_synced: falsewhile it is not — the market's ownis_synced: truefollows 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 theorderbook_subscribeDexEventsWebSocket subscription.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Invalid sending currency: Conversion error | Used assetId: "BTC" or mixed-case ERC20:0xAbC… | Use the canonical forms — see asset_id format |
| Order placed, no fill events | Subscribed to SubscribeMarketEvents (or SubscribeDexEvents) after placing the order | Subscribe at startup, then act |
Two orders placed after a DEADLINE_EXCEEDED retry | CreateOrder is not idempotent without client_order_id | Always set client_order_id on retry-prone paths |
FAILED_PRECONDITION with post_only_would_cross | A post-only order (or any limit order on a MARKET_STATE_POST_ONLY market) would have taken an order on offer | Re-price it so it does not cross — below best for a bid, above it for an ask |
FAILED_PRECONDITION with self_trade_prevented | The order's self_trade_prevention stopped it at your own resting order resting_order_id before it filled anything | Cancel or move that order, or place with SELF_TRADE_PREVENTION_CANCEL_MAKER |
FAILED_PRECONDITION with below_best_fill | The best order the order can take has a smallest fill (min_fill, at price) larger than what the order had | Re-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 answer | It filled part at once and the rest would have rested across an order it is too small for, or its self-trade rule stopped it | Read released; place the rest again if you still want it |
ALREADY_EXISTS with client_order_id_taken on a new order | The client_order_id still names an earlier order — live, or ended within the retention period | Use 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-RPC | Subscribed over a one-shot HTTP POST, which cannot carry a stream | Open a WebSocket to the same URL and subscribe there — see Subscriptions over WebSocket — or use gRPC |
Bot decodes SwapRole integer 0 as taker | SwapRole 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 computed | Derived your fee from MarketInfo's list rates, or applied listed × (1 − discount) to a maker rebate | Call GetMarketFeeRates. The formula is listed − discount × |listed| — signed, so a rebate deepens |
| A maker rebate came out smaller after earning a tier | Same sign bug, in the arithmetic | See the arithmetic |
| Bot reads a fill amount as zero or as the wrong currency | Read OrderAmount.base.amount (or .quote.amount) without checking which variant is set | OrderAmount 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 response | Mixed 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
- Swap API — direct cross-currency swaps and the auto-channel-setup
SimpleSwapflow - HTLC & Preimage API — on-chain HTLC settlement and the channel-vs-on-chain model
- Events API — full event-payload reference
- Streaming guide — subscribe-before-act, reconnection, dedup
- Common Patterns → Amounts & Decimals
/proto/orderbook.proto— authoritative schema