API migrations
Breaking changes. Five migrations are on this page, newest first. Signed
/exchange actions are unchanged by all five — only the RESPONSE shape and
the read surface move. Work through the checklists before upgrading a client.
The account-state lane split
Live. The node answers the four-lane shape and serves clearinghouse_state
and option_state. A 0.9.6 node does both.
account_state is now four lane summaries, and position detail has its own
read. The flat body carried perp margin scalars, a spot balance array and a
dex-keyed position table side by side, with nothing saying which lane a field
belonged to. It is now the account's cross-lane money figures plus one summary
per lane — perp, spot, margin, option — and the position table has left
the body.
The rule behind the split. account_state answers ONE question: what the
account is worth and how close it is to liquidation. Every figure in it is
rendered from one committed block, so the set is internally consistent. Position
DETAIL is a different question, so it gets its own read. Each lane SUMMARY stays
whole inside account_state, so no caller has to join two frames to get one
consistent number set.
Never join two frames to compute one number. A summary frame and a detail
frame can be rendered a commit apart. A health figure built from both was true at
no single block. Every frame carries height — compare it before you combine
anything.
Where every field went
Was, at the top level of account_state | Is now |
|---|---|
address, height, time | unchanged |
abstraction, position_mode | unchanged — account settings, not lanes |
account_value, total_raw_usd, withdrawable, health, tier | unchanged — each is cross-lane |
health_deferred | unchanged, still present only when true |
pm_net_value | unchanged — it stays at the top level |
total_margin_used | perp.init_margin — renamed as well as moved |
total_ntl_pos | perp.total_ntl_pos |
pm_maint_margin | perp.pm_maint_margin |
pm_concentration_penalty | perp.pm_concentration_penalty |
balances | spot.balances — the rows are unchanged, field for field |
clearinghouse_state | its own read, clearinghouse_state, same wire name and same row shape |
cross_maintenance_margin_used | detail: "margin" only — it was already only there |
Two names moved outside the body:
| Was | Is now |
|---|---|
option_positions (read) | option_state — a rename, not an alias. The old name answers unknown info type |
account_state with detail: "adl" | clearinghouse_state with detail: "adl". On account_state it is now refused with 400 |
Two new lanes have no old field to map from: margin (spot-margin collateral,
debt and pair count) and option (writer escrow, leg count, nearest expiry).
Both were previously reachable only through their own detail reads.
The traps
1. pm_net_value is NOT under perp. Three of the four pm_* figures moved
into the perp lane; this one did not. Its cash term is the whole unified USDC
pool, and under multi-collateral it also folds haircut-valued spot balances. It
is the portfolio-margin twin of account_value. A client that sums the lanes
would count the same USDC twice.
2. The held initial margin has two names, one per depth. The full body calls
it perp.init_margin. detail: "margin" calls it total_margin_used, at the
top level, exactly as before. Same number, same helper — two names. Neither depth
serves the other's name.
3. spot.balances is never an empty array. The USDC row is unconditional,
even on an account that has never been funded. An empty array is a shape no real
account returns. If you see one you are reading a placeholder, not an account —
check height, which a placeholder stamps 0.
4. option.next_expiry is absent, not zero, when option.legs is 0. A
zero timestamp reads as 1970. It is the one non-uniform key in the body.
5. tier is a string. "Safe" / "T0" / "T1" / "T2" / "T3". It has
always been a string. Type it as one, and do not accept a number in its place.
6. There is no transition window. One builder serves one shape. The old flat names are not merely dropped — they are refused at the top level, so a half-migrated body cannot ship. Prepare the client before the release, not after.
Checklist
- Re-type your account DTO. This is the urgent step. A stale account type fails to DECODE, so every account read stops working — it does not silently lag. Both client SDKs carry the new type; an older build cannot parse the new body.
- Move four reads into
perp:init_margin(fromtotal_margin_used),total_ntl_pos,pm_maint_margin,pm_concentration_penalty. - Move
balancestospot.balances. Row fields are unchanged. - Leave
pm_net_valueat the top level. Do not move it with the otherpm_*fields. - Subscribe to, or poll,
clearinghouse_statefor positions. Same wire name, same rows, its own read and its own WS channel. Both require auseron subscribe. - Rename
option_positionstooption_state, in REST calls and as a WS channel name. - Move
detail: "adl"ontoclearinghouse_state. Onaccount_stateit now answers400. - Delete any code that joins a summary and a detail frame. Take a consistent
number set from
account_statealone, or compareheightfirst. - Handle the two new lanes —
marginandoption— or ignore them safely: both are always present and zeroed.
See account_state for the full field table, and
WS subscriptions for the three channels.
The one response envelope
/info and /exchange now answer ONE envelope. A success carries data. A
failure carries a structured error object with a stable code. This replaces
the per-endpoint response and rejection shapes.
The new shape.
{ "data": { /* payload */ } }
{ "error": { "code": "ORDER_INVALID_PRICE", "message": "...", "details": { "field": "px", "limit": "100", "actual": "12345" } } }
The two keys are asymmetric on purpose. error appears only on a failure, so a
hot market-data read carries no dead field. data appears on every success and
may itself be null, because a read can succeed with no content.
What changed, by call site.
| Was | Is now | What breaks |
|---|---|---|
/info: {"type": "<t>", "data": {…}} | {"data": {"type": "<t>", …}} | Only the type READ moves, from body.type to body.data.type. Every payload field keeps its path — body.data.fills is still body.data.fills |
/exchange order path: {"statuses": […]} | {"data": {"statuses": […]}} | One level of unwrap |
/exchange admission: {"accepted": true, …} | {"data": {"accepted": true, …}} | One level of unwrap |
Any rejection: {"error": "<string>"} | {"error": {"code", "message", "details"?}} | error is an OBJECT now. A client that prints body.error prints [object Object] |
Rejection: {"accepted": false, …} | (gone) | The PRESENCE of error is the rejection. There is no accepted: false |
Per-leg: {"error": "<reason>"} in statuses | {"error": {"code", "message", "details"?}} | Same object as the envelope, at leg level |
429: {"status":"err","response":"…"} | {"error": {"code": "RATE_LIMITED", …}} | One shape for every failure now |
Checklist.
- Unwrap
data. Read the payload atbody.data, not atbody. - Move the
/infodiscriminator read frombody.typetobody.data.type. - Stop reading
erroras a string. It is an object. Readerror.code. - Replace every
messagematch with acodematch.codeis the stable contract;messageis prose and can be reworded in any release. This is the change most likely to break a client silently — grep for every comparison against an error sentence. - Stop reading
accepted: false. Test whethererroris PRESENT. - Do not treat
data: nullas a failure. It is a success with no content. Test for the presence oferror, not for a nulldata. - Drop any
422branch. No code answers422. A logically invalid request answers400with the code that names it. - Walk
statuseson a grouped batch — there is none. A batch withgroupingother than"na"is atomic: it rejects at the action level with oneerrorand nostatusesarray. Only an UNGROUPED batch reports per-leg failures.
The full code list, with the status each answers and the caller action for each, is in errors.
The account-scalar rename
Read the lane split first. It is newer and it
moves three of the fields named below into the perp lane. This section records
the RENAME; the lane split records where each renamed field now sits.
account_state now uses institution-standard names for its account-level
scalars. Two fields are renamed and two are new. Only the ACCOUNT object
changes — every position row under clearinghouse_state keeps its own field
names.
| Was | Is now | Read |
|---|---|---|
init_margin | total_margin_used | both depths |
maint_margin | cross_maintenance_margin_used | detail: "margin" only, as before |
| — | total_raw_usd (new) | both depths |
| — | total_ntl_pos (new) | full depth only |
The old names are gone, not aliased. A client that reads init_margin
receives undefined, which arithmetic turns into a silent NaN rather than an
error. Grep your client for both old names before you upgrade.
Do not run a blind find-and-replace on maint_margin. Three other fields
share the word and NONE of them changed:
| Field | Where | Status |
|---|---|---|
clearinghouse_state["<dex>"].positions[*].maint_margin | position row | unchanged — this leg's maintenance contribution |
pm_maint_margin | account object | unchanged by this rename — the portfolio-margin figure. The lane split later moved it to perp.pm_maint_margin |
maint_margin_ratio / init_margin_ratio | markets_meta | unchanged — per-market ratios, in bps |
The two new fields.
total_raw_usd— settled cash equity, whole-USDC. Realized USDC only; it excludes unrealized PnL, which is the one difference fromaccount_value. It is thesettled cashterm thewithdrawableformula starts from, so the formula is now reconcilable from one read.total_ntl_pos— mark notional of the account's CROSS positions, summed and unsigned. Isolated legs are excluded. It equals the sum of thenotionalof every position row whoseisolatedisfalse. Full depth only:detail: "margin"skips the position walk that produces it.
Why the name says cross. cross_maintenance_margin_used is the figure the
liquidation engine judges the CROSS bucket against. An isolated position posts
its own margin bucket and is liquidated per leg, so it contributes nothing to
this number. An account holding only isolated legs reports "0" and can still
be liquidated. Sizing an isolated position off this field is wrong — read
that leg's own maint_margin row instead. The old name did not say this, and
the scope was the same then.
Checklist:
- Rename
init_margin→total_margin_usedat every read site. - Rename
maint_margin→cross_maintenance_margin_used, but ONLY where you read the account object. Leave every position-row read alone. - If you derive the health ratio, it is now
account_value / cross_maintenance_margin_used— still ondetail: "margin"only. See two meanings of health. - Upgrade the client SDK.
@metaflux-dex/clientand the Rust client carry the new field names; an older SDK build cannot reach them.
See account value for the arithmetic
behind each scalar, and account_state for the
full field table.
The read-surface cut
One question, one read. The /info surface carried several reads that
answered the same question as another read. A caller had to choose, and a wrong
choice was silent. The cut removes the duplicate in every such pair and keeps
the read that answers the question completely in one round trip.
Nothing a public caller could read is gone. Every retired name has a forwarding address. The full table, with the replacement for each, is Reads that are no longer public.
The four shapes of the change:
| Shape | What to do |
|---|---|
A read merged into a bigger one — agents, sub_accounts, user_to_multi_sig_signers, user_vault_equities, delegator_summary, user_role, pm_summary, evm_contract_bindings, bridge_chain_configs | Call the read that owns the question. account_state with detail: "overview" carries the first six as named sub-objects; account_state already carries the PM figures; the EVM binding rides markets_meta kind: "spot"; no public read carries the deployment row — a node publishes it on its node_bridge_outbox stream, and the custody address per chain is in Deployments |
A read became a PARAMETER — market_info, margin_summary, account_overview (and its old name web_data), user_fills_by_time, trades_by_time, max_builder_fee | Same question, one read, one argument: coin on markets, detail: "margin" or detail: "overview" on account_state, start_time / end_time on user_fills and trades |
A read was RENAMED — spot_deploy_state → spot_deploy_auction, recent_trades → trades | Change the type string. The payload is the same |
A read a change made UNNECESSARY — encode_action | The multisig inner blob now accepts the ordinary {type, params} wire action, so there is nothing left to encode. UTF-8 encode the action you would post to /exchange and let every member sign those bytes. See signing the inner action |
A read left the public API — mip3_deployer_oracle, fba_batch_state | Operator lane. The FBA read ships publicly with its engine |
A read CAME BACK — rfq_open, rfq_user | Both are public again. They shipped with the option lane, because an accept cannot be completed without them: a taker finds its own rfq_id and a maker finds a request to answer |
A read was DELETED outright — protocol_metrics, node_info, block_info | None of the three is served any more. Every public fact protocol_metrics carried is on markets, markets_meta and staking_state; the chain id is fixed per network, see networks; the committed height and consensus time stamp every read, and the block head is on recent_blocks |
A read was DELETED outright — oracle_sources | It served a per-market source bitmask nothing acts on. Its static facts — the ten source slots and their protocol-fixed weights — are prose on oracle prices |
Two reads gained a field, and both answer a question that used to need off-wire knowledge:
markets_meta[*].signing_id— the uint32 you put in the EIP-712marketfield. It replaces the deprecatedasset_idshim. The signing type string is unchanged.markets_meta[*].risk_override— the governance risk override in force on that market,nullwhen none.
Three WS channels are retired: all_mids and active_asset_ctx (both
projections of markets rows) and user_events
(a grab-bag; every event it carried has a typed home on fills,
order_updates, ledger_updates or notifications). See
WS subscriptions.
API migration — 0.7.14
This section is history. It describes the earlier coin / address
addressing change. Where it names a query type the cut above retired, read the
cut's table for the current name.
At a glance
| Area | Old | New |
|---|---|---|
| Address a market (reads) | asset_id / market_id (numeric) | coin (symbol, e.g. "BTC") |
| Address an account (reads) | account_id or address | address (0x hex) only |
| Candle history | candle (executed-trade bars) | candle_snapshot (the single candle query) — price bars, candle_type mark (default) / oracle |
| Composite frontend snapshot | web_data2 (REST + WS) | removed — compose focused reads |
| Margin ladder | margin_table query | margin_tiers inline on markets_meta |
| Recent trades by window | — | a ranged trades ask |
| WS subscription cap | 256 / connection | 64 / connection |
1. Markets are addressed by coin
Every market-scoped read now resolves the market by its coin symbol. The
numeric asset_id / market_id request arguments are removed — a request
that supplies them (and omits coin) is rejected with
400 with INVALID_REQUEST.
Affected reads: markets, markets_meta, l2_book, trades,
funding_history, active_asset_data.
- {"type":"l2_book","market_id":0}
+ {"type":"l2_book","coin":"BTC"}
- {"type":"markets","asset_id":0}
+ {"type":"markets","coin":"BTC"}
Responses echo the coin symbol (e.g. trades rows carry "coin":"BTC").
The deprecated asset_id shim is gone; the number a SIGNER needs is
markets_meta[*].signing_id.
2. Accounts are addressed by address
Account-scoped reads no longer accept account_id; pass address (0x hex).
Affected reads: open_orders, user_fills, account_state.
- {"type":"open_orders","account_id":42}
+ {"type":"open_orders","address":"0x<addr>"}
The account_id echo field is gone from these responses.
3. Removed query types
| Removed | Returns now | Use instead |
|---|---|---|
candle | 400 unknown info type: candle | candle_snapshot |
margin_table | 400 unknown info type: margin_table | margin_tiers inline on markets_meta |
web_data2 (REST) | 400 unknown info type: web_data2 | account_state (default and detail: "overview") + open_orders + exchange_status |
web_data2 (WS channel) | unknown channel: web_data2 | account_state WS channel |
4. margin_tiers — inline notional-banded ladder
The maintenance-margin ladder now rides inline on each market record as
margin_tiers, an ascending list of upper-bound bands:
"margin_tiers": [
{ "max_open_interest": "100000", "max_leverage": 50, "maint_margin_ratio": "100" },
{ "max_open_interest": "500000", "max_leverage": 20, "maint_margin_ratio": "250" },
{ "max_open_interest": "2000000", "max_leverage": 10, "maint_margin_ratio": "500" },
{ "max_open_interest": null, "max_leverage": 5, "maint_margin_ratio": "1000" }
]
max_open_interest— upper bound of the band (decimal string, whole-USDC notional);null= the unbounded top tier.max_leverage— max leverage in this band (u8).maint_margin_ratio— maintenance-margin ratio, decimal bps string ("100"= 1.00%).
Tier = the first band whose max_open_interest is STRICTLY greater than your
position's notional — a notional landing exactly on a bound takes the next band
up. Leverage
falls and maintenance rises as open interest grows.
5. New: a ranged trades ask
Recent public prints for one market over a [start_time, end_time] window (the
bounded ring; deep history via the gateway archive):
{ "type": "trades", "coin": "BTC", "start_time": 1783000000000, "end_time": 1783011600000 }
Rows share the un-ranged trades shape.
6. markets shape
markets.data is now an object, not an array:
{ "type": "markets", "data": { "perp": [ /* market records */ ],
"spot": { "pairs": [ /* … */ ], "tokens": [ /* … */ ] } } }
Each perp[] element carries a market's dynamic fields only. The static fields (precision grids, leverage/margin ladders, trade-control flags) live separately on markets_meta, joined on (coin, kind).
7. WebSocket changes
web_data2channel removed — see the replacement above.trades:datais an array; the on-subscribe frame (is_snapshot: true) is a non-empty array of recent prints (empty only if the market never traded), and snapshot rows carryusers: null. Live pushes carryusers: [taker, maker].user_fundings: records now carry{coin, payment, szi, fundingRate, time}(paymentsigned whole-USDC: negative = paid, positive = received).explorer_txsandexplorer_blockare REMOVED. Readrecent_transactionsandrecent_blocksinstead — see Ids and wire shapes.order_updates: on afilledrecord, theorder.szis the FILLED size andorder.orig_szthe original order size.- Active channels: see the channels at a glance
table for the current set.
all_mids,active_asset_ctxanduser_eventswere retired by the cut above.
8. Predicted funding semantics
The predicted rate is on each markets
row's funding block:
rate_per_hris the clamped rate actually charged at the boundary (premium passed through the per-asset±cap), not the raw premium.next_payment_tsis the next aligned per-asset settlement boundary (ms).
Funding settles discretely at per-asset boundaries (1h default); the
funding_history samples remain the raw premium ring. The same funding block
carries interval_ms (per-asset cadence).
9. Rate limits
- Per-IP: 1200 weight / minute — allowlisted IPs exempt.
- Per-account
/exchangetoken bucket — metaliquidity-set signers exempt. - WS: 64 subscriptions per connection (down from 256) — allowlisted connections exempt.
See rate limits.
10. Unchanged
cloidis unchanged — a0x-hex string.oid/tidare NO LONGER unchanged. Both became decimal-digit strings on every response, becausetidexceeds 2⁵³ and a JSON number loses its low digits. A request still accepts either form, and the signed action payload still binds auint64oid. See Ids and wire shapes.- Signed
/exchangeactions: the typed-action digests are consensus-frozen —assetremains a numericu32in signed actions. Thecoin/addresschange is a read-API change only; it does not affect how you sign an order or cancel. SeePOST /exchange.