Rust SDK
Preview. The metaflux-client crate ships before mainnet; the API shape below is committed.
TL;DR
[dependencies]
metaflux-client = "0.20"
The client is async and works with any modern Rust async runtime (the crate itself uses tokio).
use metaflux_client::{
Client,
types::{MarketId, order::{Order, OrderKind, OrderStatus, Side, StpMode, TimeInForce}},
wallet::Wallet,
};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let wallet = Wallet::from_hex(&std::env::var("PRIVATE_KEY")?)?;
let client = Client::new("https://api.testnet.mtf.exchange")?;
let markets = client.rest().info().markets().await?;
println!("{} markets available", markets.len());
let order = Order {
owner: wallet.address(),
market: MarketId(0),
side: Side::Bid,
kind: OrderKind::Limit,
size: 1_000, // raw lots, scaled by the market's size_decimals
limit_px: 5_000_000_000_000, // 1e8 fixed-point plane
tif: TimeInForce::Gtc,
stp_mode: StpMode::CancelOldest,
reduce_only: false,
cloid: None,
builder: None,
position_side: None,
trigger: None,
};
let resp = client.exchange().submit_order(&wallet, &order).await?;
for status in &resp.statuses {
match status {
OrderStatus::Resting(r) => println!("resting: oid={}", r.oid.0),
OrderStatus::Filled(f) => println!("filled: oid={} avg_px={}", f.oid.0, f.avg_px),
OrderStatus::Error(msg) => println!("rejected: {msg}"),
}
}
Ok(())
}
There is no ClientOpts type and no .exchange / .info field on Client. Client::new(base_url) takes only the base URL — a Wallet is a separate value, passed explicitly to every signing call. Reads live under client.rest().info(); writes under client.exchange(), which takes (&wallet, ¶ms) per call, not a client-level signer.
Client and Wallet
impl Client {
pub fn new(base_url: impl Into<String>) -> Result<Self, ClientError> { /* ... */ }
}
impl Wallet {
pub fn from_hex(s: &str) -> Result<Self, ClientError> { /* ... */ }
}
Client::new takes a plain base URL string ("https://api.<net>.mtf.exchange", no trailing slash) — the SDK speaks MTF-native, served at /info · /exchange · /ws. Running the node yourself? Point at http://127.0.0.1:8080.
Wallet holds a raw secp256k1 key. It is not part of Client construction — build one from a 32-byte hex private key with Wallet::from_hex, and pass &wallet to every client.exchange() method that needs to sign. A Client needs no key at all for reads.
Client is cheap to .clone() — it wraps a connection-pooled reqwest::Client internally — so share it across tasks by cloning rather than wrapping in Arc.
Reads: client.rest().info()
let info = client.rest().info();
info.markets().await?; // Vec<MarketDynamic> — live px/funding/OI
info.markets_meta().await?; // Vec<MarketInfo> — precision grids, leverage ladders
info.l2_book("BTC", None).await?;
info.account_state(wallet.address()).await?; // collateral + margin health, four lane summaries
info.clearinghouse_state(wallet.address()).await?; // perp position rows, keyed by dex
info.option_state(wallet.address()).await?; // open option legs
info.open_orders(wallet.address()).await?;
info.user_fills(wallet.address(), None).await?;
info.funding_history("BTC").await?;
info.fee_schedule().await?;
info.vault_state(vault_addr).await?;
info.sub_accounts(wallet.address()).await?;
info.agents(wallet.address()).await?; // approved agents for this address
All return strongly-typed responses. Market reads key by coin (&str symbol); account reads key by [wallet::Address]. info.raw(json!({...})).await? is the escape hatch for a query without a dedicated wrapper.
Writes: client.exchange()
Every signed action takes (&wallet, ¶ms):
use metaflux_client::types::{
MarketId,
account::{ApproveAgent, UpdateIsolatedMargin, UpdateLeverage},
order::CancelOrder,
twap::TwapOrder,
};
let exchange = client.exchange();
exchange.cancel_order(&wallet, &CancelOrder {
owner: wallet.address(), market: MarketId(0), oid: Some(order_id), cloid: None,
}).await?;
exchange.update_leverage(&wallet, &UpdateLeverage {
asset: MarketId(0), leverage: 10, is_isolated: false,
}).await?;
exchange.update_isolated_margin(&wallet, &UpdateIsolatedMargin {
asset: MarketId(0), delta: "-12.5".to_string(), // signed decimal STRING
}).await?;
exchange.approve_agent(&wallet, &ApproveAgent {
agent: agent_address, name: Some("mm-host-3".to_string()), expires_at_ms: Some(expiry_ms),
}).await?;
exchange.twap_order(&wallet, &TwapOrder {
market: MarketId(0), side: Side::Bid, total_size: 10_000, slice_count: 10,
delay_ms: 500, reduce_only: false, position_side: None, randomize: false,
}).await?;
Most write methods return Result<Value, ClientError> (a raw JSON admission ack); submit_order / batch_order / batch_modify return the typed OrderResponse shown in the TL;DR. The full surface — cancel-by-cloid, batch order/cancel/modify, scale/chase orders, vaults, staking, spot-margin/Earn, RFQ/FBA — is one method per action on Exchange; see POST /exchange for the canonical action catalog and the crate's rest::exchange module docs for the Rust signatures.
Margin controls are perp-only. update_leverage and update_isolated_margin apply to perpetual positions only — spot trading uses the reserved-balance escrow model and does not support leverage in V1.
WebSocket: metaflux_client::ws::WsClient
The WS client is a standalone type, not a method on Client — connect it with its own URL:
use metaflux_client::{
types::MarketId,
wallet::Address,
ws::{Subscription, WsClient, WsMessage},
};
let ws = WsClient::connect("wss://api.testnet.mtf.exchange/ws").await?;
let mut rx = ws.messages();
ws.subscribe_trades(MarketId(1)).await?;
let user = Address::from_hex("0x17c5185167401ed00cf5f5b2fc97d9bbfdb7d025")?;
ws.subscribe(Subscription::Notifications { user }).await?;
loop {
let frame = rx.recv().await?;
match &frame.message {
WsMessage::Trades(payload) => println!("trade: {payload}"),
WsMessage::Notifications(payload) => println!("notification: {payload}"),
_ => {}
}
}
WsClient::connect(url) returns a handle as soon as the socket is open; .messages() returns a tokio::sync::broadcast::Receiver<WsFrame> — clone the client and call .messages() again for a second independent receiver. Each channel has a subscribe_* convenience method (subscribe_l2_book, subscribe_trades, subscribe_account_state, subscribe_markets, …); a channel without one — notifications, ledger_updates — takes the generic subscribe(Subscription::Variant { .. }). WsMessage::as_account_state() / as_open_orders() / as_order_updates() decode a raw payload into the same typed DTOs the REST reads return. Drop the client (or call .shutdown().await) to disconnect.
Numeric types
There are no wrapper types like PriceE8 / SizeE8 / UsdcE6. Order::limit_px and Order::size are plain u64 on the wire's fixed-point planes (price × 1e8; size × 10^size_decimals) — do the scaling yourself, or read [crate::grid::round_order_to_grid] to snap a human price/size onto a market's tick/lot grid before you build an order. /info reads answer in canonical decimal Strings (exact — no float precision loss); convert with your own decimal type (e.g. rust_decimal) at the boundary.
Error handling
Every fallible call returns Result<T, ClientError> — one enum, not a hierarchy split by admission/commit/network:
use metaflux_client::ClientError;
match client.exchange().submit_order(&wallet, &order).await {
Ok(resp) => { /* admitted; statuses[i] per order */ }
Err(ClientError::ProtocolError { code: 429, msg }) => {
// rate limited — msg carries the server's error string
}
Err(ClientError::ProtocolError { code, msg }) => {
// any other non-2xx response — 401/404/422/5xx, msg has the cause
}
Err(ClientError::Http(e)) => {
// the request never got a response (timeout, connection reset) —
// unknown outcome; reconcile via cloid / open_orders, don't retry blind
}
Err(e) => return Err(e.into()),
}
ClientError (from metaflux_client::ClientError, #[non_exhaustive]): Builder (bad base URL / TLS init), Http (transport failure — reqwest never got a response), Decode (JSON parse), ProtocolError { code, msg } (a non-2xx HTTP response with the server's {"error": "..."} envelope), Signature / SignatureMismatch (EIP-712 signing), InvalidKey (bad hex / wrong length), WebSocket, Validation (local input check failed before any network call). See error handling for the admission/commit/network decision tree this maps onto.
Signing externally
There is no pluggable Signer trait — Wallet holds a raw key in-process, and the public Exchange methods only accept &Wallet, not a pre-built signature. Today, an HSM or hardware-wallet integration needs to construct the EIP-712 digest itself against the wire format in typed-data signing and POST the signed envelope directly, rather than through this crate's exchange() methods.
Agent-signing pattern
There is no sender_address field. One Client serves both roles — pass whichever Wallet should sign to each call, and set the action's owner field to the account it acts for:
use metaflux_client::types::{
MarketId,
account::ApproveAgent,
order::{Order, OrderKind, Side, StpMode, TimeInForce},
};
let master_wallet = Wallet::from_hex(&std::env::var("MASTER_KEY")?)?;
let agent_wallet = Wallet::from_hex(&std::env::var("AGENT_KEY")?)?;
let client = Client::new("https://api.testnet.mtf.exchange")?;
client.exchange().approve_agent(&master_wallet, &ApproveAgent {
agent: agent_wallet.address(),
name: Some("mm-host-3".to_string()),
expires_at_ms: Some(expiry_ms),
}).await?;
// The AGENT wallet signs; `owner` names the master account.
let order = Order {
owner: master_wallet.address(),
market: MarketId(0),
side: Side::Bid,
kind: OrderKind::Limit,
size: 1_000,
limit_px: 5_000_000_000_000,
tif: TimeInForce::Gtc,
stp_mode: StpMode::CancelOldest,
reduce_only: false,
cloid: None,
builder: None,
position_side: None,
trigger: None,
};
client.exchange().submit_order(&agent_wallet, &order).await?;
Concurrency
Client and RestClient are Clone and cheap to clone — internally they share a pooled reqwest::Client, so cloning does not open a new connection pool. Wallet is also Clone; share one across tasks the same way.
Nonce generation is internal and automatic (a strictly-increasing unix-ms clock, bumped past the last value to survive a same-millisecond burst) — there is no public nonce_fn override today. Exchange::with_expires_after(ms) is the one per-handle knob the SDK exposes, folding an optional action-expiry into every typed action that handle signs.
Logging
The crate emits structured events via tracing. Install a subscriber (tracing_subscriber::fmt().init(), etc.) in your binary; the crate does not pin one.
Cargo features
[dependencies]
metaflux-client = { version = "0.20", default-features = false }
| Feature | Default | Description |
|---|---|---|
cli | yes | Compiles the mip3-deploy CLI binary (pulls in clap). Library-only consumers can turn it off with default-features = false — the Client / RestClient / WsClient API is unaffected either way. |
WebSocket support (tokio-tungstenite) and the pure-Rust TLS backend (reqwest's rustls-tls) are plain dependencies, not optional features — there is no ws / secp256k1-pure / tls-native feature matrix to choose from.
Examples
The mtf-exchange/metaflux-client-rust repository ships (cargo run --example <name>):
examples/submit_limit_order.rs— place a resting bid and print its statusexamples/stream_trades.rs— connect over WS and print the first 10 tradesexamples/devnet_market_maker.rs— quote both sides on a devnet marketexamples/create_vault.rs— create a vaultexamples/e2e_fill.rs,examples/cross_fill.rs,examples/cross_probe.rs— end-to-end fill flowsexamples/fund_evm_gas.rs— fund an EVM-side account for gasexamples/mip3_full_deploy.rs— a full MIP-3 deployer flowexamples/addr.rs— print the address for a hex private key
See also
FAQ
Show FAQ
Q: Is the SDK no-std compatible?
A: No. It needs an async runtime (tokio) and the reqwest / tokio-tungstenite HTTP/WS clients.
Q: Does it support WASM?
A: Not evaluated as part of this page — the crate depends on reqwest and tokio-tungstenite, both of which need platform-specific support to target wasm32. Treat it as native-only until stated otherwise.
Q: Can I use this from an EVM contract? A: No. This is an off-chain client. On-chain bridge interactions go through bridge primitives.