Skip to main content

POST /exchange — submit a signed action

info

Status. stable for the listed action variants. Endpoint shape committed for V1.

TL;DR

Every state-mutating user action — place order, cancel, vault deposit, agent approval, staking, etc. — is a single EIP-712-signed JSON envelope sent to POST /exchange. The action variant is selected by the type field. An order returns 200 OK with the synchronous assigned oid (the handler waits for commit); every other action returns 202 Accepted on admission, with commit confirmation arriving through the WS feed or by polling.

warning

User actions only. /exchange is the public user write path. Privileged / system writes — oracle price submission, faucet credits, SystemUserModify, SystemSpotSend, validator votes — are never on /exchange. They inject via node-local queues gated by validator authority (see the non-bridged table and the faucet). Posting a system action's native tag returns 400 with ACTION_UNSUPPORTED.

URL

POST https://api.<net>.mtf.exchange/exchange
PathWire shape
POST /exchange (gateway)MTF-native (this document)

The gateway serves the MTF-native /exchange. Running the node yourself, the same native /exchange is served directly at http://localhost:8080.

Request envelope

{
"signature": "0xabcd...1b",
"nonce": 1735689600001,
"action": {
"type": "submit_order",
"order": { /* one of the variants below */ }
}
}
FieldTypeRequiredDescription
signaturehex string, 65 bytes (130 hex chars; 0x optional)yessecp256k1 ECDSA over the EIP-712 typed-data digest of the action's structured fields + nonce. r ‖ s ‖ v. Both legacy v ∈ {27, 28} and EIP-2098 v ∈ {0, 1} accepted.
nonceuint64yesStrictly-monotonic per actor. Conventionally Date.now(). Bound into the signed digest. See idempotency.
actionobjectyesA tagged variant: { "type": "<snake_case_tag>", ... }. See Action catalog below.
expires_afteruint64 (ms)noOptional action expiry, in consensus milliseconds. Omit it or send 0 for the default (never expires) — that produces the exact same signed digest as before this field existed. A non-zero value is signed into the digest and the action is rejected once consensus time passes it. See Optional action expiry.
info

No top-level sender. The envelope carries no sender field. The account whose state mutates is determined per action:

  • Required-owner actions (submit_order, cancel_order) carry the owner inside the action body — action.order.owner / action.cancel.owner. The server recovers the signer from the signature and requires it to equal that owner or an approved agent of it.
  • Optional-owner actions — most other order / position actions (batch_order, spot_order, modify, cancel_by_cloid, scale_order, chase_order, update_leverage, RFQ, and more) — carry an optional owner. Omit it and the recovered signer is the actor; send it and an approved agent of that owner can act as it. Some of these bind owner into the signed digest, some resolve it at admission only — each action's field table says which.
  • Sender-authorized-only actions (governance, vault-leader, staking authority, …) carry no owner field at all: the recovered signer is always the actor, and action-level authorization (validator membership, vault-leader, etc.) runs at dispatch.

The server reconstructs the EIP-712 typed struct from action.type + action.params and recovers the signer over those field values — so the action.params you send must carry the same values (and the same canonical decimal strings) you put in the typed message you signed. A mismatch recovers a different signer and the request is rejected 401. See typed-data signing.

Signing

The signature is a secp256k1 ECDSA recovery over a standard EIP-712 digest. Each action is signed as structured EIP-712 typed data (eth_signTypedData_v4) with a per-action primary type MetaFluxTransaction:<Action>, so a wallet renders each field by name. The server reconstructs the typed struct from action.type + action.params, recomputes the digest, and recovers the signer:

struct_hash = keccak256( typeHash(MetaFluxTransaction:<Action>) ‖ encodeData(fields) )
signed_hash = keccak256( 0x1901 ‖ domain_separator ‖ struct_hash )

where the domain separator is:

domain_separator = keccak256(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") ‖
keccak256("MetaFlux") ‖
keccak256("1") ‖
chainId_as_uint256_be ‖
address_zero_padded_to_32
)

The per-action type strings, the atomic encodeData rules, and worked examples are in typed-data signing — the single signing scheme. A cross-implementation known-answer test pins each action's digest.

info

sig_scheme is vestigial. Earlier builds carried a sig_scheme selector on the envelope; it is no longer required and the server ignores it (typed-data recovery runs unconditionally). Omit it. If present, the only accepted value is "typed".

Chain IDs

NetworkchainId
Devnet (default)31337
Testnet114514
Mainnet8964

The signing-domain chainId must equal the node's consensus chain_id — take it from the table above, or confirm it live with the eth_chainId call in networks. Signing against the wrong chainId returns 401 because the recovered address differs from the action's owner (or, for sender-authorized actions, recovers a phantom address that passes no authorization check). See networks for endpoints.

A chainId is a signing-domain value, not a chain identifier: two chains can run the same one. To confirm WHICH chain an endpoint serves, read chain_identity on exchange_status.

Optional action expiry (expiresAfter)

Any action may carry an optional expiry so it cannot be replayed or relayed late. Send an expires_after (uint64 milliseconds) next to action / nonce / signature, and set the same value in the signed typed message:

  • 0 or absent — the default. The digest is byte-for-byte identical to the pre-existing one, so nothing changes for actions that don't opt in. Leave the field off entirely, or send 0.
  • Non-zero. The value is folded into the EIP-712 type string and appended as the final signed field (see typed-data signing → action expiry), so the expiry is signed and tamper-evident — a relay can neither strip nor alter it. The action is rejected at submission if the expiry is already in the past, and dropped at execution if consensus time passes it before it commits.
info

expires_after is a deadline, not a delay. It is a consensus timestamp in ms, not a duration. Send 0 or omit it for no expiry.

Numeric conventions

TypeWire formWhy
uint64 ≤ 2^53JSON numberSafe in IEEE-754
uint64 > 2^53, u128, scaled integersJSON stringNative JSON numbers silently lose precision past 2^53
Addresshex string "0x..."20 bytes, 40 hex chars (with or without 0x)
Booleanstrue / falseLiteral JSON
Optional fieldsnull or omitBoth accepted; null is canonical

Fixed-point fields. Price and size fields are 8-decimal fixed-point integers; USDC amounts are 6-decimal base units. The value carries the scale, not the field name — e.g. px = "10050000000" means 100.50. Always send as a string; the server parses to u128.

Signed-by semantics

An action is signed by the master key or by an approved agent wallet. One rule decides which:

An agent can sign an action only if that action carries an owner field.

There is no top-level sender field and no account header. The node reads the account from the action body. This gives exactly two classes.

ClassHow the node finds the accountWho can sign
master / agentThe action carries owner.The owner key, or an approved agent of owner.
master onlyThe action has no owner. The signer is the account.The account's own key.

The field-level tables below call the second class sender-authorized. The two names mean the same thing: no owner field, so the signer is the account. An action with an optional owner is master / agent when you send owner, and sender-authorized when you omit it.

For a master / agent action the node compares the recovered signer against owner. A signer that is neither owner nor an approved agent of owner gets 401.

danger

A master only action signed by an agent key does not fail. It acts on the agent's own account.

The node sets the account to the recovered signer. So an agent-signed bridge_withdraw debits the agent's balance, not the master's, and an agent-signed approve_agent approves an agent of the agent. You get no 401 and no error — you get the wrong account. Sign every master only action with the master key.

Each action's entry in the catalog carries its class. The two classes are the only values in the Signed-by column.

Which actions accept an agent

These actions carry an owner, so an approved agent can sign them. This list is complete.

GroupActions
Perp orderssubmit_order, batch_order, scale_order, chase_order, twap_order — the last three take a spot pair too, see the spot lane
Cancelscancel_order, batch_cancel, cancel_by_cloid, cancel_all_orders, cancel_scale, cancel_chase, twap_cancel, schedule_cancel
Amendsmodify, batch_modify
Spotspot_order, spot_cancel
Marginupdate_leverage, update_isolated_margin, top_up_isolated_only_margin, set_position_mode
Specialist venuesrfq_request, rfq_quote, rfq_acceptthe option trade path; all three refuse any market that is not a live option series — and fba_submit

Every other action is master only. That covers all fund movement (withdrawals, transfers, vaults, Earn, staking) and all account control (agent approval, sub-accounts, multi-sig, display name, referrer, builder-fee approval, portfolio-margin enrolment, abstraction config, priority bids, encrypted orders).

On submit_order and cancel_order the owner field is required. On every other action in the table above it is optional: omit it and the signer trades for itself.


Action catalog

Each variant is a tagged object { "type": "<snake_case_tag>", <flat body> }. The body keys are flat under the action object (there is no PascalCase type and no universal params wrapper) — e.g. submit_order carries an order object, cancel_order carries a cancel object, and the sender-authorized actions carry a params object.

Find your action in the tables below and click through. Every action's field-level definition lives on one page per lane:

PageActions
Perpetual ordersplace, cancel, modify, TWAP, scale, chase, triggers
Spot tradingspot_order, spot_cancel
Spot margin & Earnleveraged spot, and the lending pool that funds it
Margin & riskleverage, isolated margin, position mode
RFQ & FBAquote requests, batch auctions
Account & accessagent wallets, sub-accounts, multi-sig, margin mode
Stakingdelegate, undelegate, claim
Vaultscreate, deposit, redeem, configure
Transfers & bridgesend_asset, Core↔EVM, withdraw to an external chain
Priority & encryptedpriority_bid, submit_encrypted_order
Spot deploymentMIP-1 — register a token, list a pair
Perp deploymentMIP-3 — deploy a perpetual market
warning

px / size are unsigned fixed-point u64 on the native wire, sent as JSON numbers (the node decodes them as u64, then widens internally). Addresses are 0x-hex (40 chars); cloid is 0x + 32 hex chars (16 bytes).

Order placement & lifecycle

tip

New here? Read placing orders first. That page starts with one perp limit order end-to-end, then tiers the table below so you can skip most of it on a first integration.

typePurposeSigned-byIdempotent
submit_orderPlace one ordermaster / agentby cloid
batch_orderN orders / one signaturemaster / agentper-leg cloid
cancel_orderCancel by oidmaster / agentyes
batch_cancelN cancels / one signaturemaster / agentyes
cancel_by_cloidCancel by client order idmaster / agentyes
cancel_all_ordersCancel all (optional asset filter)master / agentyes
modifyAmend a resting order's px / sizemaster / agentyes
batch_modifyN modifies / one signaturemaster / agentper-entry
schedule_cancelFuture-block cancel-all triggermaster / agentyes
twap_orderSchedule a sliced (TWAP) ordermaster / agentby twap_id
twap_cancelCancel a running TWAP parentmaster / agentyes
scale_orderPlace an N-rung ladder / one signaturemaster / agentby cloid
cancel_scaleCancel a whole ladder by its shared cloidmaster / agentyes
chase_orderPlace a self-repricing chase leg / one signaturemaster / agentby cloid
cancel_chaseCancel a chase by its handlemaster / agentyes

Spot trading

Spot is a token-for-token CLOB (no leverage, no positions) — separate books and balances from perps. A resting spot order locks the funds it would owe on fill into a reserved balance: a bid reserves quote (its notional at the limit price), an ask reserves the base it offers. Order size is clamped at admission to what your balance funds, and fees are taken from the leg each side receives. Both actions are sender-authorized by default (omit owner and the signer is the trader); both also take an optional digest-bound owner so an approved agent can act for the account it is approved for. See spot trading for the full conceptual model.

typePurposeSigned-byIdempotent
spot_orderPlace one spot ordermaster / agentby cloid
spot_cancelCancel a resting spot order by oidmaster / agentyes

Spot margin & Earn

info

Spot margin is cross-collateralized. Leveraged spot (spot margin) draws its margin from your one unified USDC account — the same collateral that backs your perpetual positions — and its lending supply side is Earn. A pair enables only once governance calibrates its per-pair risk parameters. No pair is calibrated yet, so treat the lane as a preview: forced liquidation settles through the same path as a voluntary close (see Liquidation), but per-pair maintenance ratios are still being calibrated. Do not assume production safety at scale.

A leveraged spot position is cross-margined against your one unified USDC account — its initial-margin requirement is held against your account-wide free collateral, exactly like a perpetual open, so there is no separate collateral deposit. The buy is funded 100% by a quote borrow drawn from the pair's Earn pool, and the bought base is held segregated on the margin account (never in your spendable balances). Because collateral is shared, an open spot-margin position reduces your perpetual margin headroom, and a perpetual loss reduces the collateral that backs the spot-margin position (see margin modes). Earn is the other side — suppliers deposit the lendable quote for pool shares, and the borrow interest spot-margin traders pay lifts each share's value. All actions here are sender-authorized (the signer is the actor; there is no owner). amount / shares / borrow are decimals sent as JSON strings; size / limit_px are u64 on the 1e8 / raw-lot planes like a spot_order. Each returns the 202 Accepted admission envelope (not a synchronous oid); observe the committed outcome via /info spot_margin_state and earn_state.

typePurposeSigned-byIdempotent
spot_margin_openBorrow + IOC-buy base on leveragemaster onlyno
spot_margin_closeSell held base, repay the loanmaster onlyno
earn_depositSupply quote into the lending pool for sharesmaster onlyno
earn_withdrawRedeem pool shares (idle-bounded)master onlyno
spot_margin_deposit ⚠️Retired. It commits nothing — there is no separate collateral bucketmaster only
spot_margin_withdraw ⚠️Retired. It commits nothing — withdraw USDC from your account insteadmaster only

Why a pool pays nothing yet. A pool auto-creates on the first earn_deposit with a borrow rate of zero. Nothing on the public path can change that rate. Only the validator action createEarnPool (201) sets it, and that action is a ⅔-stake governance vote — it is not on /exchange. Until that vote passes, share value never moves and a deposit earns exactly 0. The same vote also blesses the asset as lendable and sets the pool's reserve_factor_bps. A later vote on an existing pool reconfigures only those two numbers; supply, shares and the borrow index are untouched. The rate is capped at 20000 bps per year (200%).

Spot deployment (MIP-1)

Six sender-authorized actions let any account register a spot token, list a pair for it, price it, open it, and mint its genesis supply. The signer is the deployer — there is no owner field, and every later call on a token or pair is refused unless the signer is the deployer of record. See MIP-1 for the conceptual model.

Registering a token or a pair charges a deploy fee at the moment it commits. The fee is the current Dutch-clock ask on that stream, and it is paid from your free collateral, not from a pre-posted bid. You bound it with max_deploy_fee: if the ask is above the value you signed, the call is rejected and nothing is charged. There is no bid, no escrow and no refund step anywhere in this lane.

typePurposeSigned-byCharges a deploy fee
spot_register_tokenRegister a new spot tokendeployer (sender)yes — TokenRegister stream
spot_register_pairList a (base, quote) trading pairdeployer (sender)yes — SpotPairDeploy stream
spot_set_pair_paramsSet the pair's fee tier + min notionalpair deployerno
spot_set_pair_activeOpen or close the pair to new orderspair deployerno
spot_seed_holdersStage genesis holder rows (repeatable)token deployerno
spot_finalize_supplyCheck the staged total, then mint oncetoken deployerno

Every action here returns the 202 Accepted admission envelope. Confirm the allocated ids and the committed spec through /info spot_meta.

Perp deployment (MIP-3)

Eleven sender-authorized actions let an account register a perp market in its own dex, configure it, open it, and price it. The signer is the deployer. After the first registration, only that market's deployer — or a sub-deployer holding the matching permission bit — may call the rest. A market lands in the deployer's own dex, never in the primary dex. See MIP-3 and the field-level sections.

Every action here is refused unless governance leaves the mip3_enabled off-switch open, and unless the target is a MIP-3 deployer market. A core market listed by governance is not one, so this lane cannot reach it.

typePurposeSigned-byCharges a deploy fee
perp_register_assetRegister a perp market, and create your dex on the first calldeployer (sender), or a delegate holding bit 8yes — Dutch-clock ask
perp_set_leverageSet max leveragedeployer, or bit 1no
perp_set_fee_tierSet the taker, maker and deployer feesdeployer, or bit 2no
perp_set_maker_rebateSet the maker rebatedeployer, or bit 3no
perp_set_min_sizeSet the minimum order sizedeployer, or bit 4no
perp_activate_marketOpen the market to tradingdeployer, or bit 5no
perp_deactivate_marketClose the market, and cancel every resting order and parked trigger on itdeployer, or bit 6no
perp_set_sub_deployersGrant a delegate every bit, or revoke itdeployer onlyno
perp_set_sub_deployer_permsGrant a delegate an exact permission maskdeployer onlyno
mip3_set_oracle_pxPush the market pricedeployer, or bit 0no
perp_set_oracle ⚠️Retired. It writes a mask nothing readsdeployerno

A delegate cannot delegate. Both perp_set_sub_deployers and perp_set_sub_deployer_perms need the deployer's own key. A delegate holding every other bit still cannot grant or edit a delegation.

Margin & risk

typePurposeSigned-by
update_leverageChange leverage / iso toggle on an assetmaster / agent
update_isolated_marginSigned isolated-margin deltamaster / agent
top_up_isolated_only_marginStrict-iso margin top-upmaster / agent
user_portfolio_marginEnroll / unenroll PMmaster only
pm_unenrollUnenroll from PM — a no-params alias for user_portfolio_margin with enroll: falsemaster only
borrow_lendSupply to, or draw from, the BOLE liquidation backstop poolmaster only

RFQ, FBA & utility

Request-for-quote (RFQ) block trading, the frequent-batch-auction (FBA) entry, and the deliberate no-op. See the field-level sections for the wire planes and the digest-bound owner rule.

The three RFQ actions are the option trade path. They take an option series signing_id as the market, and they refuse every other market. See options.

typePurposeSigned-by
rfq_requestOpen an RFQ session (taker)master / agent (owner digest-bound)
rfq_quoteQuote onto an open RFQ (maker)master / agent (owner digest-bound)
rfq_acceptAccept a quote and settle (taker)master / agent (owner digest-bound)
fba_submitSubmit into a batch-auction windowmaster / agent
noopDeliberate no-op (nonce burn / keepalive)master only

Account management

typePurposeSigned-by
approve_agentApprove an agent walletmaster only
set_display_nameSet the account handlemaster only
set_referrerBind to a referrer addressmaster only
approve_broker_feeApprove a broker fee ceilingmaster only
claim_referral_rewardsClaim accrued referral creditmaster only
claim_broker_rewardsClaim accrued broker-code creditmaster only
approve_builder_feeThe older spelling of approve_broker_fee. It still decodes and behaves identicallymaster only
claim_builder_rewardsThe older spelling of claim_broker_rewards. It still decodes and behaves identicallymaster only
create_sub_accountOpen a sub-account under the mastermaster only
sub_account_transferMove perp cross-collateral parent ↔ submaster only
sub_account_spot_transferMove a spot token balance parent ↔ submaster only
convert_to_multi_sig_userLift account to multi-sigmaster only
multi_sigRun one inner action as a multi-sig account, carrying the roster signaturesany submitter; the roster authorizes
set_position_modeToggle one-way / hedge position modemaster / agent

Staking & abstraction

typePurposeSigned-by
c_depositMove spot MTF into the free staking balancemaster only
c_withdrawMove the free staking balance back to spot MTFmaster only
token_delegateDelegate / undelegate stakemaster only
claim_rewardsClaim staking rewardsmaster only
link_staking_userAlias a staking targetmaster only
user_set_abstractionSelf-scope abstraction configmaster only
agent_set_abstractionNot available — every call is refusedmaster only
priority_bidPay a priority fee for block-front placementmaster only

Encrypted orders

typePurposeSigned-by
submit_encrypted_orderThreshold-encrypted order ciphertextmaster only
encrypted_order_submit ⚠️Retired alias. Refused at every height; post the canonical name

Vaults

typePurposeSigned-by
create_vaultLeader creates a vaultmaster only
vault_transferLeader seed transfermaster only
vault_modifyLeader-only vault config updatemaster only
vault_distributeFollower deposit into a vault, from the signer's own accountmaster only
vault_withdrawFollower share redemptionmaster only
register_metaliquidity_operatorLeader grants or revokes an operator key on a Metaliquidity vaultvault leader only

Transfers

Value moves inside the Core ledger: to another account, or between your own spot and perp balances. Both are sender-authorized — there is no owner field, so an agent signature moves the AGENT's own balance, never the master's.

typePurposeSigned-by
send_assetSend one token to another accountmaster only
usd_class_transferMove your own USDC between spot and perpmaster only

spot_send and usd_send are NOT actions. They are ledger record kinds on the ledger_updates feed, which say what a committed transfer DID. Posting either name gets unknown variant, the same error a misspelt action gets.

Bridge withdrawals

Value leaves the Core ledger for MetaFluxEVM, or leaves the chain over MetaBridge. Every action here is master only: the recovered signer is the account debited. An agent signature debits the agent's own account, never the master's.

typePurposeSigned-by
core_evm_transferMove a spot asset from the Core ledger to MetaFluxEVM, optionally with an EVM payloadmaster only
send_to_evm_with_data ⚠️The same Core → EVM move in the Hyperliquid-compatible field shape. Live. It refuses five things Hyperliquid accepts and ignores — see the sectionmaster only
bridge_withdrawWithdraw USDC cross-collateral to an external chainmaster only
withdraw ⚠️Retired. The legacy CCTP withdrawal. It is refused at commit, and always has beenmaster only

Both Core → EVM rows reach the same lane and land the same credit. Which one to use is decided by one thing: the field shape your client already has.

Not on the public /exchange path

These are draft / legacy action names from earlier docs. Most are not bridged on the MTF-native /exchange handler — they are either privileged / system writes that must never transit the public user path, or recognized-but-unmapped schema stubs, and posting them returns 400. Read the error code, not just the status. A name the action enum does not carry fails decode and returns INVALID_REQUEST (unknown variant); a name the enum does carry but the public path refuses returns ACTION_UNSUPPORTED. The one exception below is MultiSig, which is bridged (its native tag is multi_sig). See the table below for the disposition of each, and governance actions for the names that decode but never execute here.

Draft nameNative tag (if recognized)Why not bridged
UpdateMarginModeNo native action; isolation is the is_isolated flag on update_leverage
MultiSigmulti_sigBridged and executing — the collect-and-execute wrapper is the live way a multi-sig account acts. It verifies the roster signatures and runs the inner action. (A non-wrapped action from a multi-sig account is still rejected.)
RegisterReferrerNot bridged (referrer is bound by address via set_referrer)
UsdcTransfer / SpotTransferUser-to-user transfer flows not bridged
WithdrawUsdcDraft name; external withdrawal is bridge_withdraw
(legacy CCTP withdraw)withdrawRetired — admitted, then rejected at every commit since genesis ("withdraw3 disabled; use bridge_withdraw"). Use bridge_withdraw
(BOLE pool)borrow_lendBridged and liveparams.kind "Lend" / "UnLend" / "Repay" are open to any account; "Borrow" is refused unless the sender is an approved liquidator
(vault distribute)vault_distributeBridged and live — a follower's own self-service deposit; see vaults
(PM lifecycle)pm_enroll / pm_unenrollpm_enroll has no native tag — enroll via user_portfolio_margin. pm_unenroll is a bridged alias (no params) for the same action's enroll:false form. pm_rebalance is retired — rejected as an unknown action
(cross-chain)Not an /exchange action at all. cross_chain_send is not in the action enum, so it fails decode and returns 400 INVALID_REQUEST (unknown variant) — the same answer a misspelt action name gets, not ACTION_UNSUPPORTED. Cross-chain transfer is a different wire: CrossChainSend is CoreWriter action 19, called from MetaFluxEVM

Governance and validator actions: in the enum, refused at the door

A governance or validator action decodes on /exchange but never executes there. This matters because the two failures look nothing alike, and integrators read the first one as encouragement:

  • A name the enum does not know fails decode: 400 INVALID_REQUEST, unknown variant. The error lists every accepted name, and the names below are in that list.
  • A name from this section decodes, so it gets past the schema, and then hits the refusal: 400 ACTION_UNSUPPORTED.

Read the code, not the message. ACTION_UNSUPPORTED is the contract. The text behind it differs by tag, and there are at least three of them: governance actions are not accepted on this surface, unsupported action: unsupported native action variant: <tag>, and action does not support typed signing. Match on the code; a substring match on any one of those sentences fails for most of the table.

Where the refusal lands also differs. Most of these tags are refused after signature recovery. A few are refused before it, so a valid-looking AUTH_BAD_SIGNATURE for one tag and an immediate ACTION_UNSUPPORTED for another say the same thing: not open. Neither is progress.

Reaching ACTION_UNSUPPORTED is not progress. A valid signature from a real validator key gets the same answer. The public /exchange path carries no governance or validator write, whoever signs it. These actions enter through the node's own operator lane, which only a validator operator has. Each accepted vote is one vote: the change enacts only when ⅔ of stake has voted for a byte-identical payload. Two validators who differ by one character vote for two different proposals and neither reaches quorum.

The table lists every such tag the node accepts today. No client sends these. They are listed so that a reader who meets one in the unknown variant list, or in gov_history, knows what it is.

Native tagWhoWhat it does
gov_voteValidator, ⅔ stakeAye or nay on a gov_propose proposal. ⅔ aye enacts it; ⅔ nay discards it
vote_globalValidator, ⅔ stakeSets one global parameter directly, with no proposal step
set_mark_modeValidator, ⅔ stakeSets a perpetual market's mark mode: automatic, oracle-synced, or a fixed price
set_pm_shock_gridValidator, ⅔ stakeSets the price and volatility shock grids that portfolio margin stresses positions against
arm_featuresValidator, ⅔ stakeArms protocol features to activate at a future height or time. The signature binds the feature list, so a replayed vote cannot arm a different list
approve_upgradeValidator, ⅔ stakeSchedules or cancels the coordinated halt at which every validator swaps binaries
c_validatorThe validator itselfMaintains its own record: commission, active flag, self-jail, unjail, deregister. Not a vote; one signed action applies. A commission raise waits out a notice period; a cut applies at once
vote_app_hashThe validator node itselfThe checkpoint state-hash vote every validator node emits on its own after each checkpoint. No person casts it
gov_actionA validator, on its own node onlyCarries one signed governance or validator action. The node accepts it only from its own machine, so a public caller gets AUTH_UNAUTHORIZED, not ACTION_UNSUPPORTED

Two more tags in this set, set_metaliquidity_set and gov_adjust_spot_value, keep their payloads and signing types below, because their value hashing rule is the one operators get wrong. The types are consensus-frozen. They are not an invitation to post the action to /exchange.

Native tagPayloadEffect
set_metaliquidity_set{"address": "0x<hex>", "allowed": <bool>}Adds (true) or removes (false) an account from the Metaliquidity operator set. This is the set a vault leader's register_metaliquidity_operator grant is checked against
gov_adjust_spot_value{"account": "0x<hex>", "value": "<decimal>"}Sets that account's cross-account USDC value to value. A target, not a delta
MetaFluxTransaction:SetMetaliquiditySet(string metafluxChain,address account,bool allowed,uint64 nonce)
MetaFluxTransaction:GovAdjustSpotValue(string metafluxChain,address account,string value,uint64 nonce)
warning

gov_adjust_spot_value.value is hashed VERBATIM. It is a whole-USDC decimal string, and the digest takes keccak256 of the exact bytes you send. The chain does not re-parse or re-format it, so "100", "100.0" and "100.00" are three different signatures and three different votes. Send the signed string through byte for byte: do not trim a trailing zero, do not let a JSON library round-trip it through a float, and do not normalize it.

The value also discriminates the proposal — the vote is tallied against the decimal it decodes to, so a differently-scaled spelling is a different proposal. Agree the exact figure and its scale before the vote, and have every validator send that one spelling.

Both payloads reject the zero address.


Response

The envelope

Every /exchange response is one envelope, the same one /info answers. A success carries data. A failure carries error. The two keys never appear together.

{ "data": { /* payload */ } }
{
"error": {
"code": "ORDER_INVALID_PRICE",
"message": "price off grid: 12345 is not a multiple of tick_size 100",
"details": { "field": "px", "limit": "100", "actual": "12345" }
}
}

code is the stable contract — match on it. message is prose and can change in any release — never match on it. details is present only when the rejection names a bound, and is omitted rather than sent as {}. Every code, its status and the action to take are in the error reference.

The HTTP status keeps its normal meaning. It does not replace the envelope, and the envelope does not replace it.

The payload inside data depends on the action class:

  • Order-type actionssubmit_order, batch_order, spot_order, scale_order, chase_order200 OK with a statuses array (the handler waits for commit + dispatch and returns the real assigned oid).
  • All other actions → the admission payload: 200 OK when the commit is observed inside the wait window, 202 Accepted when it is not. Treat both as admitted, and read committed.
  • Any admission-time rejection → the error envelope, at the status its code maps to.

200 OK — order path (synchronous oid)

An order-type action blocks up to the node's order-wait window (default 5 s) so the response carries the real oid + resting/filled status. On timeout it returns a pending entry — never a fabricated oid.

The echoed oid is a decimal-digit STRING. Every id on a response is, so a JavaScript client cannot lose digits — see Ids and wire shapes. The oid you put inside a SIGNED cancel or modify payload stays a uint64 number: the typed digest binds uint64 oid and is consensus-frozen. A batch_order / scale_order resolves to one entry per leg or rung, a parked trigger leg included; a single order to one entry.

{ "data": { "statuses": [ { "resting": { "oid": "12345", "cloid": "0x..." } } ] } }

Per-order statuses

statuses holds one entry per leg, in input order. Each entry is a single-key object naming the leg's outcome:

{ "resting": { "oid": "12345", "cloid": "0x..." } } // posted to book (cloid echoed only here, only if sent)
{ "filled": { "oid": "12345", "total_sz": "100000000", "avg_px": "10050000000" } } // matched
{ "error": { "code": "MARGIN_INSUFFICIENT", "message": "..." } } // this leg was rejected
{ "noop": { "reason": "position already flat, nothing to reduce" } } // accepted, and it changed nothing
{ "parked": { "oid": "12345", "cloid": "0x..." } } // trigger leg accepted, and held off the book
{ "pending": { "action_hash": "0x<keccak>", "nonce": 1735689600001 } } // admitted but no commit seen in the wait window

noop — accepted, and it did nothing

A reduce_only order against a position that is already flat, or one whose reducible size clamps to zero, is accepted. It burns the nonce, it places nothing, and there is nothing left for it to reduce.

noop is a success, and it MUST NOT be retried. That is the whole reason it is not an error. The two outcomes need opposite handling:

EntryWhat happenedWhat to do
errorThe leg was refused. Nothing is on the bookFix the cause named by code, then resend
noopThe leg was accepted and had no effectNothing. Re-read the position before you size another reduce

reason is free prose that says which no-op it was — an already-flat leg, or a size that clamped to zero. Branch on the noop key, never on reason, the same rule that says match an error on code and never on message.

A noop entry carries no oid: no order was created, so no id was assigned. Do not read one out of it and do not cancel against one.

parked — accepted, and held off the book

A TP/SL or stop leg is parked. It holds a real oid and it is an open order, but it never rests on the book. It carries no depth, so l2_book does not show it. The chain fires the leg when the mark crosses its trigger price.

A parked entry is accepted. Do not retry it. Cancel it by its oid with cancel_order, or by its cloid with cancel_by_cloid. Both reach a parked leg.

The rule callers get wrong: a position_tpsl group places no book order at all, so parked entries are its WHOLE answer. That group used to answer an empty statuses array, and a mixed normal_tpsl batch answered fewer entries than it sent legs.

Not live yet: parked ships with the next node release. A live node leaves every parked leg out of statuses, so the array is shorter than the request. parked is the approved term across this reference — on order_status alone the same state answers the legacy token triggered.

A failed leg carries the SAME error object as the envelope — the same code, the same prose message, and the same optional details. There is one error shape on this API, at both levels. So the leg handler and the envelope handler are the same function:

{
"data": {
"statuses": [
{ "resting": { "oid": "12345", "cloid": "0x...aa" } },
{ "error": {
"code": "ORDER_INVALID_PRICE",
"message": "price off grid: 12345 is not a multiple of tick_size 100",
"details": { "field": "px", "limit": "100", "actual": "12345" }
} }
]
}
}

Note where that response sits: it is a success envelope. The action was admitted and ran, so the top level carries data and status 200. One leg failed inside it. A 200 does not mean every leg rested — walk the array.

Per-leg failures happen only in an UNGROUPED batch

groupingBehaviourWhere a failure appears
"na" (default), and every single-order actionPer-leg. Each leg runs its own gate. A bad leg does not roll back the good onesInside statuses, as that leg's error entry. The envelope is still a success
"normalTpsl", "positionTpsl"ATOMIC. All-or-nothing. If any leg cannot be admitted — including a protective leg that cannot park — the whole action is rejected and nothing is placedAt the ACTION level: the envelope carries error, and there is no statuses array
danger

A grouped batch never reports a per-leg failure. Do not write a handler that looks for one — on a grouped batch there is no statuses array to walk when it fails. Read error.code on the envelope instead.

The reason the grouped batch is atomic: per-leg behaviour could fill the entry leg and fail the protective leg, and leave a position with no stop. A grouped batch places the whole family or places nothing.

A pending entry means the action was admitted and may still commit later. There is no /info query that takes an action_hash — track the order on the order_updates WS channel, which carries the committed outcome including a rejected status.

202 Accepted — non-order admission

Every non-order action (cancel, margin, vault, staking, governance, …) returns the admission payload. The status code is 200 OK when the action commits inside the wait window and 202 Accepted when it does not; the body is the same either way:

{
"data": {
"accepted": true,
"mempool_depth": 3,
"nonce": 1735689600001,
"action_hash": "0x<action_hash>"
}
}

mempool_depth is informational at admission time. action_hash is the deterministic identifier of the submission. It is 0x + keccak256 of the exact signed action bytes concatenated with the sender address (20 bytes) and the nonce (8 bytes, big-endian). Because the sender and nonce are bound into the hash, two submissions with byte-identical action params produce different action_hash values, so a resubmit never collides with an earlier one.

accepted is not committed

danger

"accepted": true means the action entered the MEMPOOL. It does not mean the action ran. Admission checks the signature, the agent approval and the nonce shape — nothing else. Every business rule (position mode, collateral, feature gates, parameter bounds, ownership) runs later, when the block commits.

A commit-time rejection of a non-order action pushes on no channel. No WS channel carries the failure. This is not specific to one action — it is how every non-order action behaves. You must ASK for the verdict; nothing tells you.

The two classes differ, so treat them differently:

Action classCommit-time rejectionHow to confirm
Order-typesubmit_order, batch_order, spot_order, scale_order, chase_orderReported. The 200 OK body carries a per-leg error object in statuses, and order_updates pushes a rejected statusRead the statuses array; a pending entry means read order_updates
Every other actiontwap_order, cancels, margin, vault, staking, governance, …Reported in this response. The call waits for the commit, so a rejection returns the error envelope, and success returns committed: trueRead committed on the payload. A 202 means the wait expired, not that the action failed — read the EFFECT the action was supposed to have

Confirm by effect. Each action's own section names the read that proves it landed — a TWAP parent on user_twaps, a leverage change on account_state, a cancel by the order's absence from open_orders. Poll that read for a few blocks. If the effect has not appeared, the action was rejected; resubmit with a corrected body rather than waiting.

tip

Read committed, not accepted.

accepted: true means only "admitted to the mempool". An action can be admitted and then rejected at commit, so accepted alone reads as a success it does not promise.

committed: true means the action committed AND applied. committed: false marks a response that reports admission and nothing more — which happens only when the wait expired.

There is no separate verdict read. The wait is about fifty blocks, so the answer is in this response. If you get a 202, RE-READ the state the action was meant to change. Re-submitting the same nonce is replay-safe, and the block builder answers the replay with NONCE_REPLAYED.

The most common silent rejection is a position-mode mismatch. A hedge account must name position_side on an order and cannot use twap_order at all; a one-way account must omit position_side. Read position_mode from account_state once at session start and build every order body from it.

Rejection envelope

An admission-time rejection carries no data key. The body is the error object and nothing else:

{
"error": {
"code": "AUTH_BAD_SIGNATURE",
"message": "signature: expected 130 hex chars, got 4"
}
}

There is no accepted: false field any more. The presence of error is the rejection.

400 Bad Request — malformed

Match on code. The message column shows a representative sentence only — it is prose and it can change.

This is the WHOLE admission taxonomy. Admission checks the request shape, the signature, the agent approval, the nonce and the cloid — nothing else. So a 400 carries one of the four codes below, or one of the AUTH_* codes. Every order-body, collateral and market rule runs at COMMIT and answers a 200 instead.

error.codeCauseRemediation
INVALID_REQUESTA field is missing, mis-sized or unparseable — a signature that is not 130 hex chars, an owner that is not 40 hex chars, an action that fails to parse, an empty orders / cancels array, a number above 2^128 - 1Fix the field the message names. Do not retry the same bytes
ACTION_UNSUPPORTEDThe action variant is recognised but not bridged on /exchange, or a field selects a behaviour with no core equivalent — tif: "aon", stp_mode: "reject", a stop_loss / take_profit with no trigger blockSee the non-bridged table and use a supported value
ORDER_DUPLICATE_CLOIDsubmit_order reused a client order id on the same accountUse a fresh cloid. Check first whether the earlier submission rested
PRECONDITION_FAILEDA state rule refused the action and the rule has no code of its own — a trailing callback of 0, a trailing leg on the wrong side, an owner-less action that is not sender-authorizedRead message for the reason. Do not match on it

Four PRECONDITION_FAILED cases are worth naming, because the fix is not obvious from the sentence:

  • trail_px: 0 on a trigger block. Presence of the key selects the trailing signing type, so an explicit 0 is a present trail, not an absent one. Omit the key entirely — see trailing stops.

  • A trailing leg on the take-profit side. The ratchet follows a winning position, so only the stop-loss may trail. Put trail_px on the protective leg.

  • market settled — trading closed. A delist closed this market for good, and every order on it is refused, reduce-only included. A retry never succeeds. Test settled on markets rather than the message. See Delisting a perp market. NOT LIVE YET: a live node never sends it.

  • this node is not on the exchange-serving allowlist. Nothing in your request is wrong, and the message carries no address. The node you reached does not serve POST /exchange writes. Treat it as a routing failure: retry the identical bytes against another endpoint. Re-signing, a new nonce, or waiting changes nothing.

    A node can start refusing at any time, with no restart and no version change, so handle this on every write rather than only at startup. The public endpoint is api.testnet.mtf.exchange; an aggregator you run yourself can point at a node that does not serve writes.

Commit-time codes — carried by a 200, never a 400

The codes below name an order-body, collateral or market rule. None of them is an admission rejection. The rule needs block-execution context — the live book, the account after the fills that landed first, the market's open interest at that moment — so the chain cannot answer it at admission. The verdict comes back on a 200, and where you read it depends on the action class:

Action classWhere the code appears
Order-typesubmit_order, batch_order, spot_order, scale_order, chase_order200 OK, in that leg's error entry inside statuses. The envelope itself is a success — walk the array
Every other action200 OK whose whole body is the rejection envelope. A 202 means the wait expired, not that the action failed
error.codeCauseRemediation
ORDER_INVALID_PRICEpx is off the tick grid. Carries detailsRound to a multiple of details.limit
ORDER_INVALID_SIZEsize is off the lot grid. Carries detailsRound to a multiple of details.limit
ORDER_ZERO_SIZESize is zero or negativeSend a positive size
ORDER_BELOW_MIN_NOTIONALPrice × size is under the market minimumIncrease the size
ORDER_SELF_TRADEThe two sides of an rfq_accept are one party. This is the RFQ lane only — on the order book, self-trade prevention CANCELS an order and never mints this codeQuote or accept from an account outside the taker's STP group
MARGIN_INSUFFICIENTThe account cannot fund the requirement. Carries detailsdetails.limit is free collateral, details.actual is what is needed
MARKET_INACTIVEThe market is disabled, closed or reduce-only. A perp that a delist halted or settled, or that governance paused, answers PRECONDITION_FAILED insteadSend a closing order, or wait
MARKET_OI_CAPOpen interest is at the market capNothing in the request is wrong. Wait, or trade elsewhere
ASSET_INSUFFICIENT_BALANCEThe spot balance cannot fund the transfer, withdrawal or spot order. Not live yet for a spot order: a live node accepts an unfunded spot order as a no-opCheck the free balance; a held balance is not spendable

PRECONDITION_FAILED reaches you from both points: admission mints it for the shape rules above, and the commit mints it for every state rule that has no code of its own. Read the status to tell them apart — a 400 refused the request, a 200 refused the effect.

danger

Do not treat any code in this table as "the request was malformed". The request was well formed, it was admitted, and it burned its nonce. Resending the identical bytes never re-runs the action — see a replayed nonce. Fix the cause, then re-sign with a fresh nonce.

401 Unauthorized — signature / authorization failed

error.codeCause
AUTH_BAD_SIGNATUREThe signature does not recover — malformed bytes, a bad recovery id v, or the wrong signing-domain chainId, which recovers a phantom address
AUTH_UNAUTHORIZEDThe recovered address is neither the action's owner nor an approved agent of it
AUTH_AGENT_FORBIDDENThe signer IS an agent of the owner, but the approval has expired or does not cover this action
info

Recovery runs first. The handler recovers the signer over the raw action bytes before parsing the typed action. So a request with both a bad signature and an unknown action type answers 401 AUTH_BAD_SIGNATURE, not a 400. Anti-replay (nonce uniqueness) is enforced in committed state (a 64-wide per-account sliding window), not at admission. A reused nonce is admitted at the HTTP edge and refused by the block builder, which answers NONCE_REPLAYED at 200 — never a 401.

A replayed nonce

An action whose nonce the committed window already holds never reaches a block. The block builder drops it and answers the waiting caller:

{
"error": {
"code": "NONCE_REPLAYED",
"message": "nonce replayed: this account used this nonce, or it sits more than 64 below the newest"
}
}

The status is 200, because this is a commit verdict and not an admission refusal. On an order action the same object arrives as statuses[0].error. Nothing committed, and the nonce is not consumed. Do not retry at the same nonce — re-sign at a higher one.

Why an honest nonce can be refused. The window is 64 wide, and it is anchored on the HIGHEST nonce the account has ever committed. One action signed far in the future — a wrong clock — moves that anchor forward. Every later Date.now() nonce then sits more than 64 below the anchor. The chain refuses each one until the wall clock passes the anchor. Recover by signing above the anchor.

Not live yet: the verdict ships with the next node release. A live node drops the replay with no answer at all, so the caller waits out the order window and the gateway then answers a 502.

429 Too Many Requests — rate-limited

{
"error": {
"code": "RATE_LIMITED",
"message": "rate limit exceeded"
}
}

No retry hint is sent. Derive the wait from the refill rate — /exchange costs 5 weight and the per-IP bucket refills at 20 weight per second, so 250 ms buys back one request. See rate limits.

500 and 503 — not your request

{ "error": { "code": "UNAVAILABLE", "message": "gateway overloaded" } }
error.codeHTTPMeaning
INTERNAL500Our defect — arithmetic overflow or a broken invariant. message is always the literal internal error. Retry, then report it. There is nothing to fix in the request
UNAVAILABLE503An upstream is down or the gateway's in-flight pool is full. Back off from 200 ms. Sustained UNAVAILABLE is an operator incident

A full mempool is never an UNAVAILABLE. The node's pending-action queue does not refuse a new action — it drops the OLDEST pending one. See Admission ≠ commit below.


Admission ≠ commit

202 means accepted to the mempool. It does not mean:

  • Included in a block (admitted actions can be evicted on cap pressure before the next leader proposes).
  • Succeeded at the state machine (e.g. an order with reduce-only-violation passes admission but errors at commit).

Track commit status via the WS feedorder_updates / fills — or poll /info for open_orders / user_fills. Correlate by cloid: the action_hash returned at admission is not echoed on any per-account WS event today. No feed carries it. The explorer_txs channel that used to is removed, and its replacement recent_transactions has no hash field. For a per-action verdict, read action_outcome.

Sequence diagram — place an order and see it on the book

Edge cases

Show edge cases
  • Race between ApproveAgent and first agent-signed order. Submit ApproveAgent, await its commit via order_updates or by polling /info, then start agent traffic. Or, accept that the first 1–2 requests will 401 and retry with linear backoff for a couple of committed blocks.
  • Cancel arrives after fill commits. Returns "order not found". Harmless. Watch fills first if accuracy matters.
  • Order admits but fails at commit (e.g. reduce-only violation discovered post-admit because of intervening fills). The statuses entry carries an error object; the order is not on the book.
  • Numeric overflow on fixed-point fields. Anything fitting in u128 is accepted. An encoded string above 2^128 - 1 is rejected 400 with INVALID_REQUEST.
  • Empty batch_order.orders / batch_cancel.cancels. Rejected at admission 400 with INVALID_REQUEST.
  • Cross-block atomicity. A batch_order with multiple legs is block-atomic — all legs see the same begin-block state. They are NOT cross-block atomic (a second order action in a later block sees the result of the first).

See also

FAQ

Show FAQ

Q: How are actions signed? A: As EIP-712 structured typed data (eth_signTypedData_v4), one primary type per action (MetaFluxTransaction:<Action>), so wallets (MetaMask, Rabby, Ledger) render each field by name instead of an opaque blob. The server reconstructs the typed struct from action.type + action.params, recomputes the digest, and recovers the signer — so action.params must carry the same field values (and the same canonical decimal strings) you signed. A cross-implementation known-answer test pins each action's digest. Full spec: typed-data signing.

Q: Can I batch unrelated actions in one request? A: No. Each request is one action. For multi-order batching use batch_order (an orders: [] array under one signature), for multi-cancel use batch_cancel (a cancels: [] array), and so on.

Q: What's the smallest possible request? A: A cancel of a single oid: ~250 bytes including the 65-byte signature and 40-char sender. Most orders are 350–500 bytes.

Q: How do I deal with 429? A: Back off on a fixed schedule of your own — the response carries no retry_after_ms. Order-flow bots should pre-emptively rate-limit on the client side: /exchange costs 5 weight against a per-IP budget that refills at 20 weight per second, so one IP sustains 4 orders per second. See rate limits.

Q: Does nonce need to be a timestamp? A: No. It needs to be strictly increasing per sender. Convention is Date.now() because that's monotonic and human-readable in logs, but any monotonic uint64 works.