Bullet Trading API
Welcome to the Bullet Trading API documentation. Everything you need to integrate with Bullet — from placing your first order to streaming real-time market data.
API Reference
- Connectivity — environments, endpoints, and connection URLs
- REST API — Binance FAPI-compatible endpoints for trading, account, and market data
- WebSocket API — streaming market data, order updates, and request-response operations
Guides
- Account Setup — create an account and get a signing key
- Delegate Accounts — secure, revocable API trading access
- Transaction Signing — build, sign, and submit transactions to the Bullet exchange
- Transaction Fields — uniqueness, TxDetails, gas limits, and paymaster
- Decimal Encoding — convert fixed-point integers to
RustDecimalfor borsh serialization - WebSocket Consistency Guarantees — snapshot delivery, reconnection behavior, and message ordering
SDKs
- Rust — type-safe client with REST, WebSocket, transaction signing, and keypair management
Connectivity
Environments
| Environment | Host |
|---|---|
| Mainnet | tradingapi.bullet.xyz |
| Testnet | tradingapi.testnet.bullet.xyz |
| Staging | tradingapi.staging.bullet.xyz |
Endpoints
| Protocol | URL | Docs |
|---|---|---|
| REST | https://<host>/fapi/v1/... | Interactive API docs |
| WebSocket | wss://<host>/ws | WebSocket spec |
Bullet Trading API WebSocket Specification
WebSocket endpoint: ws://<host>:<port>/ws
- Localnet:
ws://localhost:3000/ws - Staging:
wss://tradingapi.staging.bullet.xyz/ws - Testnet:
wss://tradingapi.testnet.bullet.xyz/ws - Mainnet:
wss://tradingapi.bullet.xyz/ws
- Bullet Trading API WebSocket Specification
Connection Lifecycle
WebSocket connections go through three phases: establishment, keepalive, and disconnection. The server uses status messages to inform clients of connection state changes (e.g., successful connection, impending disconnection with reason).
Status Message
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "status" | - (no equivalent) |
| E | u64 | event time (μs) | - |
| status | string | "connected" or "disconnecting" | - |
| clientId | string | UUID client identifier | - |
| reason | string? | disconnect reason (only for disconnecting) | - |
Connection Establishment
- Client connects to WebSocket endpoint
- Server assigns unique
clientId - Server sends
"connected"status message
{
"e": "status",
"E": 1706745600000000,
"status": "connected",
"clientId": "ws_abc123"
}
Disconnection
The server sends a "disconnecting" status message before closing:
{
"e": "status",
"E": 1706745600000000,
"status": "disconnecting",
"clientId": "ws_abc123",
"reason": "pong_timeout"
}
Disconnect Reasons
| Reason | Default | Description |
|---|---|---|
idle_timeout | 60s | no valid message within timeout |
pong_timeout | 60s | client didn’t respond to ping |
max_duration | 24h | connection exceeded max lifetime |
Keepalive (Ping/Pong)
Connections are kept alive via WebSocket ping/pong frames. The server sends ping frames every 30 seconds; clients must respond with pong frames. WebSocket ping frames reset the keepalive timeout.
Ping
Ping the server to check connection health. Alternatively clients can also send WebSocket ping frames.
{
"method": "ping",
"id": 4
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "ping" or "PING" | (WebSocket ping frame) |
| id | u64 | no | request correlation id | - |
Possible errors: ValidationError (message parse)
Responses: Pong
Pong
{
"e": "pong",
"id": 4,
"E": 1706745600000000
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "pong" | (WebSocket pong frame) |
| id | u64? | echoed request id | - |
| E | u64 | event time (μs) | - |
Subscription Streams
All client messages use JSON format with method field. Method names are case-insensitive.
Subscribe
Subscribe to market data topics or user data streams.
{
"method": "subscribe",
"id": 1,
"params": [
"BTC-USD@aggTrade",
"ETH-USD@depth10"
]
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "subscribe" or "SUBSCRIBE" | method: "SUBSCRIBE" |
| params | string[] | yes | array of topic strings | params |
| id | u64 | no | request correlation id | id |
Behavior: Idempotent with atomic validation. All topics validated first - if any fails, entire request fails. Already-subscribed topics silently skipped.
Possible errors: ValidationError, TooManyRequests, InvalidSubscriptionFormat, Unauthorized, InvalidSymbol,
ClientNotFound
Responses:
Subscribe (success)
{
"e": "subscribe",
"id": 1,
"E": 1706745600000000,
"result": "success"
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "subscribe" | - (Binance uses result: null) |
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| result | string | "success" | result: null |
Unsubscribe
Unsubscribe from market data topics or user data streams.
{
"method": "unsubscribe",
"id": 5,
"params": [
"BTC-USD@aggTrade"
]
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "unsubscribe" or "UNSUBSCRIBE" | method: "UNSUBSCRIBE" |
| params | string[] | yes | topics to unsubscribe | params |
| id | u64 | no | request correlation id | id |
Behavior: Idempotent - always succeeds. Invalid or non-subscribed topics silently skipped.
Possible errors: ValidationError (message parse)
Responses:
- Success: Unsubscribe
Unsubscribe (success)
{
"e": "unsubscribe",
"id": 5,
"E": 1706745600000000,
"result": "success"
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "unsubscribe" | - (Binance uses result: null) |
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| result | string | "success" | result: null |
ListSubscriptions
List all active subscriptions for the client.
{
"method": "list_subscriptions",
"id": 6
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "list_subscriptions" or "LIST_SUBSCRIPTIONS" | method: "LIST_SUBSCRIPTIONS" |
| id | u64 | no | request correlation id | id |
Possible errors: ValidationError (message parse)
Responses:
- Success: ListSubscriptions
ListSubscriptions (success)
{
"e": "list_subscriptions",
"id": 6,
"E": 1706745600000000,
"result": [
"BTC-USD@depth10",
"ETH-USD@aggTrade"
]
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "list_subscriptions" | - |
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| result | string[] | subscribed topics | result: […] |
Request-Response
OrderPlace
Place an order via WebSocket.
{
"method": "order.place",
"id": 10,
"params": {
"tx": "c2lnbmVkX3RyYW5zYWN0aW9uX2J5dGVz"
}
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "order.place" or "ORDER.PLACE" | method: "order.place" |
| params.tx | string | yes | signed base64-encoded borsh transaction bytes | (different: REST params) |
| id | u64 | no | request correlation id | id |
Possible errors: ValidationError (empty tx), ServiceUnavailable (submission failed), Timeout,
NewOrderRejected
Responses:
- Success: OrderResult
- Failure: OrderError
OrderCancel
Cancel an order via WebSocket.
{
"method": "order.cancel",
"id": 11,
"params": {
"tx": "c2lnbmVkX3RyYW5zYWN0aW9uX2J5dGVz"
}
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "order.cancel" or "ORDER.CANCEL" | method: "order.cancel" |
| params.tx | string | yes | signed base64-encoded borsh transaction bytes | (different: orderId/clientOrderId) |
| id | u64 | no | request correlation id | id |
Possible errors: ValidationError (empty tx), ServiceUnavailable (submission failed), Timeout, CancelRejected
Responses:
- Success: OrderResult
- Failure: OrderError
OrderAmend
Amend an existing order via WebSocket.
{
"method": "order.amend",
"params": {
"tx": "<signed-base64-borsh-tx>"
},
"id": 7
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "order.amend", "order.modify", or "ORDER.AMEND" | - |
| params.tx | string | yes | signed base64-encoded borsh transaction bytes | - |
| id | u64 | no | request correlation id | id |
Possible errors: ValidationError (empty tx), ServiceUnavailable (submission failed), Timeout, CancelRejected
Responses:
- Success: OrderResult
- Failure: OrderError
OrderCancelAll
Cancel all open orders for a market via WebSocket.
{
"method": "order.cancelAll",
"params": {
"tx": "<signed-base64-borsh-tx>"
},
"id": 8
}
| Field | Type | Required | Description | Binance Equivalent |
|---|---|---|---|---|
| method | string | yes | "order.cancelAll" or "ORDER.CANCEL_ALL" | - |
| params.tx | string | yes | signed base64-encoded borsh transaction bytes | - |
| id | u64 | no | request correlation id | id |
Possible errors: ValidationError (empty tx), ServiceUnavailable (submission failed), Timeout, CancelRejected
Responses:
- Success: OrderResult
- Failure: OrderError
Error
Error response to any client request.
{
"e": "error",
"id": 2,
"E": 1706745600000000,
"error": {
"param": "invalid-topic-format",
"code": -1004,
"msg": "invalid subscription format: expected <symbol>@<stream>"
}
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "error" | - |
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| error.code | i32 | error code | error.code |
| error.msg | string | error message | error.msg |
| error.param | string? | parameter that caused error | - (Bullet-specific) |
OrderResult
Response to OrderPlace, OrderCancel, OrderAmend, or OrderCancelAll.
{
"e": "order.place",
"id": 10,
"E": 1706745600000000,
"results": {
"tx_id": "0xabc123def456",
"status": "processed",
"order_ids": [
1
],
"client_order_ids": [
1
]
}
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "order.place", "order.cancel", "order.amend", "order.cancelAll" | - |
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| results.tx_id | string | transaction hash | - (DEX-specific) |
| results.status | string | tx status ("processed", "skipped") | - (DEX-specific) |
| results.order_ids | u64[] | affected order ids | - (DEX-specific) |
| results.client_order_ids | u64[] | affected client order ids | - (DEX-specific) |
OrderError
Error response to OrderPlace, OrderCancel, OrderAmend, or OrderCancelAll.
{
"id": 12,
"E": 1706745600000000,
"error": {
"code": -2010,
"msg": "new order rejected: insufficient margin"
}
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| id | u64? | echoed request id | id |
| E | u64 | event time (μs) | - |
| error.code | i32 | error code | error.code |
| error.msg | string | error message | error.msg |
Market Data
All market data messages are pushed to subscribed clients. No type wrapper.
DepthUpdate
Topic: SYMBOL@depth, SYMBOL@depth5, SYMBOL@depth10, SYMBOL@depth20
{
"e": "depthUpdate",
"E": 1706745600000000,
"T": 1706745600000000,
"s": "BTC-USD",
"U": 1000,
"u": 1000,
"pu": 0,
"b": [
[
"50000.00",
"1.5"
],
[
"49999.00",
"2.0"
]
],
"a": [
[
"50001.00",
"1.2"
],
[
"50002.00",
"3.0"
]
],
"mt": "s"
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "depthUpdate" | e |
| E | u64 | event time (μs) | E |
| T | u64 | transaction time (μs) | T |
| s | string | symbol | s |
| U | u64 | first update id | U |
| u | u64 | last update id (always equals U) | u |
| pu | u64 | previous update id | pu |
| b | [[price, qty], …] | bids (descending) | b |
| a | [[price, qty], …] | asks (ascending) | a |
| mt | string | "s" (snapshot) or "u" (update) | - (Bullet-specific) |
Note: U equals u and as we don’t batch updates.
AggTrade
Topic: SYMBOL@aggTrade
{
"e": "aggTrade",
"E": 1706745600000000,
"s": "BTC-USD",
"a": 200001,
"p": "50000.50",
"q": "0.5",
"f": 200001,
"l": 200001,
"T": 1706745600000000,
"m": false,
"th": "0xabc123def456",
"ua": "0xuser123",
"oi": 100001,
"mk": false,
"ff": true,
"lq": false,
"fe": "0.025",
"nf": "0.025",
"fa": "USD",
"sd": "BUY"
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "aggTrade" | e |
| E | u64 | event time (μs) | E |
| s | string | symbol | s |
| a | u64 | aggregate trade id | a |
| p | string | price | p |
| q | string | quantity | q |
| f | u64 | first trade id | f |
| l | u64 | last trade id | l |
| T | u64 | trade time (μs) | T |
| m | bool | is buyer maker | m |
| th | string | transaction hash | - (DEX-specific) |
| ua | string | trader address | - (DEX-specific) |
| oi | u64 | order id | - (DEX-specific) |
| mk | bool | is maker | - (DEX-specific) |
| ff | bool | fully filled | - (DEX-specific) |
| lq | bool | liquidation trade | - (DEX-specific) |
| fe | string | fee amount | - (DEX-specific) |
| nf | string | net fee | - (DEX-specific) |
| fa | string | fee asset | - (DEX-specific) |
| co | string? | client order id | - (DEX-specific) |
| sd | string | "BUY" or "SELL" | - (DEX-specific) |
BookTicker
Topic: SYMBOL@bookTicker, !bookTicker
{
"e": "bookTicker",
"u": 1000,
"E": 1706745600000000,
"T": 1706745600000000,
"s": "BTC-USD",
"b": "50000.00",
"B": "1.5",
"a": "50001.00",
"A": "1.2",
"mt": "s"
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "bookTicker" | e |
| u | u64 | update id | u |
| E | u64 | event time (μs) | E |
| T | u64 | transaction time (μs) | T |
| s | string | symbol | s |
| b | string | best bid price | b |
| B | string | best bid qty | B |
| a | string | best ask price | a |
| A | string | best ask qty | A |
| mt | string | "s" (snapshot) or "u" (update) | - (Bullet-specific) |
MarkPrice
Topic: SYMBOL@markPrice, !markPrice@arr
{
"e": "markPriceUpdate",
"E": 1706745600000000,
"s": "BTC-USD",
"p": "50000.50",
"i": "50000.00",
"r": "0.0001",
"T": 1706774400000000
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "markPriceUpdate" | e |
| E | u64 | event time (μs) | E |
| s | string | symbol | s |
| p | string | mark price | p |
| i | string | index price (median CEX price) | i |
| P | string? | estimated settle price | P |
| r | string | funding rate | r |
| T | u64? | next funding time | T |
| th | string? | transaction hash | - (DEX-specific) |
Liquidation (ForceOrder)
Topic: SYMBOL@liquidations, SYMBOL@forceOrder, !liquidations, !forceOrder, liquidations, forceOrders
{
"e": "liquidation",
"E": 1706745600000000,
"o": {
"s": "BTC-USD",
"S": "SELL",
"o": "LIMIT",
"f": "IOC",
"p": "49000.00",
"ap": "49000.00",
"X": "FILLED",
"l": "1.0",
"T": 1706745600000000,
"th": "0xabc123def456",
"ua": "0xuser123",
"oi": 100001,
"ti": 200001
}
}
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "liquidation" | e ("forceOrder") |
| E | u64 | event time (μs) | E |
| o.s | string | symbol | o.s |
| o.S | string | side ("BUY" or "SELL") | o.S |
| o.o | string | order type ("LIMIT") | o.o |
| o.f | string | time in force ("IOC") | o.f |
| o.q | string? | original quantity | o.q |
| o.z | string? | filled quantity | o.z |
| o.p | string | price | o.p |
| o.ap | string | average price | o.ap |
| o.X | string | status ("FILLED") | o.X |
| o.l | string | last filled qty | o.l |
| o.T | u64 | trade time (μs) | o.T |
| o.th | string | transaction hash | - (DEX-specific) |
| o.ua | string | liquidated address | - (DEX-specific) |
| o.oi | u64 | order id | - (DEX-specific) |
| o.ti | u64 | trade id | - (DEX-specific) |
OrderUpdate
Topic: [email protected], ADDRESS@ORDER_TRADE_UPDATE
Published for order lifecycle events (NEW, TRADE, CANCELED).
{
"e": "orderTradeUpdate",
"E": 1706745600000000,
"o": {
"s": "BTC-USD",
"i": 100001,
"X": "NEW",
"x": "NEW",
"T": 1706745600000000,
"th": "0xabc123def456",
"ua": "0xuser123",
"S": "BUY",
"o": "LIMIT",
"f": "GTC",
"p": "50000.00",
"q": "1.0"
}
}
Common fields (all events):
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| e | string | "orderTradeUpdate" | e ("ORDER_TRADE_UPDATE") |
| E | u64 | event time (μs) | E |
| o.s | string | symbol | o.s |
| o.i | u64 | order id | o.i |
| o.X | string | order status | o.X |
| o.x | string | execution type | o.x |
| o.T | u64 | transaction time (μs) | o.T |
| o.th | string | transaction hash | - (DEX-specific) |
| o.ua | string | user address | - (DEX-specific) |
NEW order additional fields:
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| o.S | string | side | o.S |
| o.o | string | order type | o.o |
| o.f | string | time in force | o.f |
| o.p | string | price | o.p |
| o.q | string | quantity | o.q |
TRADE fill additional fields:
| Field | Type | Description | Binance Equivalent |
|---|---|---|---|
| o.S | string | side | o.S |
| o.l | string | last filled qty | o.l |
| o.L | string | last filled price | o.L |
| o.n | string | commission | o.n |
Topics
Symbol-Based Topics
| Bullet Topic | Aliases | Binance Equivalent | Description |
|---|---|---|---|
SYMBOL@depth | symbol@depth | orderbook (default 10) | |
SYMBOL@depth5 | symbol@depth5 | orderbook 5 levels | |
SYMBOL@depth10 | symbol@depth10 | orderbook 10 levels | |
SYMBOL@depth20 | symbol@depth20 | orderbook 20 levels | |
SYMBOL@aggTrade | symbol@aggTrade | trades | |
SYMBOL@bookTicker | symbol@bookTicker | best bid/offer | |
SYMBOL@markPrice | symbol@markPrice | mark price + funding | |
SYMBOL@ticker | symbol@ticker | 24hr ticker | |
SYMBOL@liquidations | SYMBOL@forceOrder | symbol@forceOrder | liquidations |
SYMBOL@kline_INTERVAL | symbol@kline_INTERVAL | candlesticks (coming soon) |
Symbol format: Bullet uses BTC-USD (hyphen), Binance uses btcusdt (lowercase, no separator)
Kline intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d (coming soon)
Parse errors: InvalidFormat, MissingSymbol, InvalidDepth, InvalidInterval, UnknownTopic
Resolution errors: SymbolNotFound
Broadcast Topics
| Bullet Topic | Aliases | Binance Equivalent |
|---|---|---|
tickers | !ticker@arr, !ticker | !ticker@arr |
markPrices | !markPrice@arr, !markPrice | !markPrice@arr |
bookTickers | !bookTicker, !bookTicker@arr | !bookTicker |
liquidations | !liquidations, !forceOrder, forceOrders | !forceOrder@arr |
Parse errors: InvalidFormat, UnknownTopic
User Data Topics
| Bullet Topic | Aliases | Binance Equivalent |
|---|---|---|
[email protected] | ADDRESS@ORDER_TRADE_UPDATE | listenKey stream |
Key difference: Bullet uses address-prefixed topics directly. Binance requires a listenKey from REST API.
Resolution errors: MissingUserAddress (when address not provided)
Speed Suffixes
Speed suffixes are accepted but ignored: @100ms, @500ms, @1s
Example: BTC-USD@depth@100ms is equivalent to BTC-USD@depth
Error Codes
General
| Code | Name | Description | Binance Code |
|---|---|---|---|
| -1000 | Unknown | unknown error | -1000 |
| -1001 | Disconnected | server busy/disconnected | -1001 |
| -1002 | Unauthorized | authentication required | -1002 |
| -1003 | TooManyRequests | rate limit exceeded | -1003 |
| -1006 | UnexpectedResponse | unexpected response | -1006 |
| -1007 | Timeout | request timeout | -1007 |
| -1014 | UnknownOrder | order not found | -1014 |
| -1015 | TooManyOrders | order rate limit | -1015 |
| -1016 | ServiceUnavailable | service down | -1016 |
| -1020 | UnsupportedOperation | operation not supported | -1020 |
| -1021 | InvalidTimestamp | bad timestamp | -1021 |
| -1022 | InvalidSignature | signature invalid | -1022 |
Parameters
| Code | Name | Description | Binance Code |
|---|---|---|---|
| -1102 | MandatoryParamMissing | required param missing | -1102 |
| -1111 | BadPrecision | precision error | -1111 |
| -1116 | InvalidOrderType | bad order type | -1116 |
| -1117 | InvalidSide | bad side | -1117 |
| -1122 | InvalidSymbol | invalid symbol | -1122 |
| -1123 | InvalidUserAddress | invalid address | - (Bullet-specific) |
Subscriptions
| Code | Name | Description | Binance Code |
|---|---|---|---|
| -1004 | InvalidSubscriptionFormat | bad topic format | - (Bullet-specific) |
| -1005 | SymbolNotFound | symbol not found | - (Bullet-specific) |
| -1008 | ValidationError | validation failed | - (Bullet-specific) |
| -1010 | SubscriptionExists | already subscribed | - (Bullet-specific) |
Orders
| Code | Name | Description | Binance Code |
|---|---|---|---|
| -2010 | NewOrderRejected | order rejected | -2010 |
| -2011 | CancelRejected | cancel failed | -2011 |
| -2013 | NoSuchOrder | order doesn’t exist | -2013 |
| -2014 | ApiKeyFormatInvalid | bad api key | -2014 |
| -2015 | InvalidApiKeyIpPermissions | auth failure | -2015 |
| -2021 | OrderWouldTrigger | would trigger immediately | -2021 |
Internal
| Code | Name | Description | Binance Code |
|---|---|---|---|
| -4001 | ClientNotFound | client not found | - (internal) |
| -4002 | CouldNotSendMessage | could not send message | - (internal) |
Notes
DEX-Specific Fields
DEX-specific fields use 2-letter codes for compactness:
| Code | Full Name | Description |
|---|---|---|
th | tx_hash | on-chain transaction hash |
ua | user_address | user’s wallet address |
oi | order_id | sequencer order ID |
ti | trade_id | sequencer trade ID |
mk | is_maker | whether the trade was a maker |
ff | is_full_fill | fully filled indicator |
lq | is_liquidation | liquidation trade indicator |
fe | fee | fee amount |
nf | net_fee | net fee after rebates |
fa | fee_asset | fee asset symbol |
sd | side | trade side (BUY/SELL) |
co | client_order_id | client-provided order ID |
Message Type Field
The mt field in orderbook and BBO messages indicates:
"s"- snapshot (complete state at that depth level)"u"- update (incremental changes since last update)
Response Format Differences
- All responses use
efield for event type (e.g.,"e":"subscribe","e":"error") - Order responses still use Binance-style format with
statuscode - Market data messages use
efield for event type (e.g.,"e":"depthUpdate")
Guides
- Account Setup — create an account, get a signing key, and start trading via the API
- Delegate Accounts — register a separate keypair for secure, revocable API trading access
- Transaction Signing — build, sign, and submit transactions (place orders, cancel orders, replace, amend, cancel all) to the Bullet exchange
- Transaction Fields — uniqueness, TxDetails, gas limits, and paymaster
- Decimal Encoding — convert fixed-point integers to
RustDecimalfor borsh serialization, with examples in C++, Python, and JavaScript - WebSocket Consistency Guarantees — snapshot delivery, backend reconnection behavior, and message ordering
Account Setup
This guide covers how to set up an account for trading via the API.
Overview
To trade via the API you need a delegate key — a separate keypair authorized to trade on your behalf, managed from the webapp. Delegate keys are revocable and cannot withdraw funds, making them the secure choice for API trading.
1. Create an account
Sign in at app.bullet.xyz (or app.testnet.bullet.xyz for testnet) with your wallet. This creates an embedded wallet that serves as your trading account on Bullet. On testnet, funds are airdropped automatically on signup.
2. Deposit funds
Deposit collateral through the webapp UI.
3. Set up a delegate key
Generate an ed25519 keypair locally and register it as a delegate — see Delegate Accounts for the full setup. The delegate key can place orders and deposit on your behalf, but cannot withdraw. You can revoke it at any time from the webapp.
4. Start trading
With a signing key in hand, you can build and submit transactions. See Transaction Signing for the full signing flow.
Query your account state at any time — no authentication needed for read endpoints:
GET /fapi/v3/account?address=<your_main_address>
Note: When using a delegate key, use your main account’s address (not the delegate’s) for all read endpoints. Balances, positions, and orders live on the main account — the delegate key is only used for signing transactions.
Trades made via the API with a delegate key are visible in the webapp UI.
Delegate Accounts
A delegate is a separate keypair authorized to trade on behalf of your main account. Orders placed by a delegate are attributed to the delegator’s account — they share the same balances, positions, and margin.
Why use delegates
- Security: your main wallet key stays in your wallet; the delegate key is the only secret you handle - if the delegate key is compromised, your main wallet remains untouched
- Scoped access: delegates can only trade, but cannot deposit or withdraw
- Revocable: delegate access can be removed anytime by your main account
- Visibility: all trades made by delegates appear in the webapp UI under your account
Setup
1. Generate a keypair
Generate an ed25519 keypair locally. This will be your delegate key.
Solana CLI (easiest):
solana-keygen new --outfile delegate-key.json --no-bip39-passphrase
solana-keygen pubkey delegate-key.json
The generated file contains the 64-byte secret key (first 32 bytes are the private key, last 32 are the public key). The printed public key (base58) is what you register in the webapp.
Rust:
#![allow(unused)]
fn main() {
use ed25519_dalek::SigningKey;
let keypair = SigningKey::generate(&mut rand::thread_rng());
let public_key = hex::encode(keypair.verifying_key().to_bytes());
let private_key = hex::encode(keypair.to_bytes());
println!("public key: {public_key}"); // register this in the webapp
println!("private key: {private_key}"); // use this to sign API transactions
}
Python:
from nacl.signing import SigningKey
keypair = SigningKey.generate()
public_key = keypair.verify_key.encode().hex()
private_key = keypair.encode().hex()
print(f"public key: {public_key}") # register this in the webapp
print(f"private key: {private_key}") # use this to sign API transactions
Store the private key securely. You will need the public key for the next step.
2. Register the delegate in the webapp
- Sign in at app.bullet.xyz
- Navigate to More → Delegate Accounts
- Enter a name and your delegate’s public key
- Confirm — this signs a delegation transaction with your main wallet
3. Trade with the delegate key
Sign transactions with the delegate’s private key. The exchange resolves the delegate to your main account automatically — no special fields or flags needed.
#![allow(unused)]
fn main() {
// sign with the delegate keypair — order executes on the main account
let order_bytes = create_place_order_bytes(
&delegate_keypair,
chain_id,
&chain_hash,
market_id,
orders,
);
}
See Transaction Signing for the full signing flow.
Note: For read endpoints (account info, balances, positions, open orders), always use your main account’s address — not the delegate’s. All state lives on the main account.
Capabilities
| Operation | Delegate | Main wallet |
|---|---|---|
| Place / cancel orders | Yes | Yes |
| Deposit | Yes | Yes |
| Withdraw | No | Yes |
Revoking a delegate
From the webapp, go to More → Delegate Accounts and remove the delegate. Revocation is immediate — the delegate key can no longer submit transactions for your account.
Limits
- Up to 10 delegates per account
- Delegate names are max 20 characters
- A delegate address cannot already have its own account
- A delegate cannot be registered to multiple accounts
Transaction Signing
All write operations (placing orders, cancelling, deposits) are submitted as signed transactions to the Bullet exchange. This page describes how to build, sign, and submit a transaction.
This will be codified into official SDKs in the near future, but for now you can use the following Rust code as a reference implementation. The same principles apply in any language: build the call message, wrap in an unsigned transaction, sign, wrap in a signed transaction, and submit.
Overview
1. Build call message (e.g. PlaceOrdersCall, CancelAllOrders)
2. Wrap in UnsignedTransaction (call + uniqueness + tx details)
3. Sign borsh(unsigned_tx) ++ chain_hash → ed25519 sign
4. Wrap in SignedTransaction (signature + pubkey + unsigned_tx)
5. Submit borsh(signed_tx) → base64 → POST or WS
Prerequisites
Fetch exchange info once to obtain chain_id and chain_hash:
GET /fapi/v1/exchangeInfo
From the response, extract:
chainInfo.chainId— u64 chain identifierchainHash— 32-byte hex string, decode to[u8; 32]
You also need an ed25519 keypair for signing.
Dependencies
[dependencies]
borsh = { version = "1", features = ["derive"] }
ed25519-dalek = { version = "2.1", features = ["rand_core"] }
rust_decimal = "1.37"
rand = "0.8"
base64 = "0.22"
Rust Types
All types are borsh (borsh.io) serialized. Enum discriminants and struct field ordering must match
the schema exactly (GET /rollup/schema).
#![allow(unused)]
fn main() {
// ── borsh types ──
// enum discriminants and struct field order must match the rollup schema
// fetch canonical schema: GET /rollup/schema
#[derive(Clone, BorshSerialize)]
pub struct Amount(pub u128);
#[derive(Clone, BorshSerialize)]
pub struct TxDetails {
pub max_priority_fee_bips: u64,
pub max_fee: Amount,
pub gas_limit: Option<[u64; 2]>,
pub chain_id: u64,
}
#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum UniquenessData {
Nonce(u64) = 0,
Generation(u64) = 1,
}
#[derive(Clone, Copy, BorshSerialize)]
pub struct MarketId(pub u16);
#[derive(Clone, Copy, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum Side {
Bid = 0,
Ask = 1,
}
#[derive(Clone, Copy, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum OrderType {
Limit = 0,
PostOnly = 1,
FillOrKill = 2,
ImmediateOrCancel = 3,
PostOnlySlide = 4,
PostOnlyFront = 5,
}
#[derive(Clone, Copy, BorshSerialize)]
pub struct ClientOrderId(pub u64);
#[derive(Clone, Copy, BorshSerialize)]
pub struct OrderId(pub u64);
/// rust_decimal::Decimal layout: { flags, hi, lo, mid } as u32
#[derive(Clone, Copy, BorshSerialize)]
pub struct SurrogateDecimal {
pub flags: u32,
pub hi: u32,
pub lo: u32,
pub mid: u32,
}
#[derive(Clone, BorshSerialize)]
pub struct NewOrderArgs {
pub price: SurrogateDecimal,
pub size: SurrogateDecimal,
pub side: Side,
pub order_type: OrderType,
pub reduce_only: bool,
pub client_order_id: Option<ClientOrderId>,
pub pending_tpsl_pair: Option<PendingTpslPair>,
}
#[derive(Clone, Copy, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum TriggerPriceCondition {
Mark = 0,
Oracle = 1,
LastTrade = 2,
}
#[derive(Clone, BorshSerialize)]
pub struct TpslLeg {
pub trigger_price: SurrogateDecimal,
pub order_price: Option<SurrogateDecimal>,
pub trigger_condition: TriggerPriceCondition,
}
#[derive(Clone, BorshSerialize)]
pub struct TpslPair {
pub take_profit: Option<TpslLeg>,
pub stop_loss: Option<TpslLeg>,
}
#[derive(Clone, BorshSerialize)]
pub struct PendingTpslPair {
pub tpsl_pair: TpslPair,
pub dynamic_size: bool,
}
#[derive(Clone, BorshSerialize)]
pub struct CancelOrderArgs {
pub order_id: Option<OrderId>,
pub client_order_id: Option<ClientOrderId>,
}
#[derive(Clone, BorshSerialize)]
pub struct AmendOrderArgs {
pub cancel: CancelOrderArgs,
pub place: NewOrderArgs,
}
#[derive(Clone, BorshSerialize)]
pub struct AmendOrdersCall {
pub market_id: MarketId,
pub orders: Vec<AmendOrderArgs>,
pub sub_account_index: Option<u8>,
}
#[derive(Clone, BorshSerialize)]
pub struct PlaceOrdersCall {
pub market_id: MarketId,
pub orders: Vec<NewOrderArgs>,
pub replace: bool,
pub sub_account_index: Option<u8>,
}
#[derive(Clone, BorshSerialize)]
pub struct CancelOrdersCall {
pub market_id: MarketId,
pub orders: Vec<CancelOrderArgs>,
pub sub_account_index: Option<u8>,
}
#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum ExchangeUserAction {
PlaceOrders(PlaceOrdersCall) = 20,
AmendOrders(AmendOrdersCall) = 21,
CancelOrders(CancelOrdersCall) = 22,
CancelMarketOrders {
market_id: MarketId,
sub_account_index: Option<u8>,
} = 23,
CancelAllOrders {
sub_account_index: Option<u8>,
} = 29,
}
#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum ExchangeCallMessage {
User(ExchangeUserAction) = 0,
}
/// only Exchange variant needed; discriminant = 7 encodes correctly via repr(u8)
#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum RuntimeCall {
Exchange((ExchangeCallMessage,)) = 7,
}
#[derive(Clone, BorshSerialize)]
pub struct UnsignedTransaction {
pub runtime_call: RuntimeCall,
pub uniqueness: UniquenessData,
pub details: TxDetails,
}
#[derive(Clone, BorshSerialize)]
pub struct TransactionV0 {
pub signature: [u8; 64],
pub pub_key: [u8; 32],
pub tx: UnsignedTransaction,
}
#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum SignedTransaction {
V0(TransactionV0) = 0,
}
}
Helpers
#![allow(unused)]
fn main() {
use std::time::{SystemTime, UNIX_EPOCH};
use rust_decimal::Decimal;
/// parse a decimal string into rust_decimal::Decimal
pub fn parse_decimal(s: &str) -> Decimal {
s.parse().expect("valid decimal string")
}
/// convert rust_decimal::Decimal to SurrogateDecimal using the public unpack API
pub fn to_surrogate(d: Decimal) -> SurrogateDecimal {
let u = d.unpack();
SurrogateDecimal {
flags: (u.scale << 16) | if u.negative { 1 << 31 } else { 0 },
hi: u.hi,
lo: u.lo,
mid: u.mid,
}
}
pub fn timestamp_us() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time after unix epoch")
.as_micros() as u64
}
pub fn default_tx_details(chain_id: u64) -> TxDetails {
TxDetails {
max_priority_fee_bips: 0,
// Adjust to your needs;
max_fee: Amount(1 << 48),
// None defaults to block limit; use Some([5_000_000, 5_000_000]) for explicit caps
gas_limit: None,
chain_id,
}
}
}
Signing
#![allow(unused)]
fn main() {
use borsh::BorshSerialize;
use ed25519_dalek::{Signer, SigningKey};
pub fn sign_transaction(
keypair: &SigningKey,
chain_hash: &[u8; 32],
unsigned: UnsignedTransaction,
) -> Vec<u8> {
let mut msg = borsh::to_vec(&unsigned).expect("borsh serialize");
msg.extend_from_slice(chain_hash);
let signature: [u8; 64] = keypair.sign(&msg).to_bytes();
let pub_key: [u8; 32] = keypair.verifying_key().to_bytes();
let signed = SignedTransaction::V0(TransactionV0 {
signature,
pub_key,
tx: unsigned,
});
borsh::to_vec(&signed).expect("borsh serialize signed tx")
}
}
Place Orders
#![allow(unused)]
fn main() {
/// build signed PlaceOrders transaction bytes
pub fn create_place_order_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
market_id: u16,
orders: Vec<NewOrderArgs>,
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::PlaceOrders(PlaceOrdersCall {
market_id: MarketId(market_id),
orders,
replace: false,
sub_account_index: None,
}),
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Cancel Orders
#![allow(unused)]
fn main() {
/// build signed CancelOrders transaction bytes
pub fn create_cancel_order_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
market_id: u16,
orders: Vec<CancelOrderArgs>,
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::CancelOrders(CancelOrdersCall {
market_id: MarketId(market_id),
orders,
sub_account_index: None,
}),
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Cancel Market Orders
Cancels every open order on a given market. No order list needed — just the market_id.
#![allow(unused)]
fn main() {
/// build signed CancelMarketOrders transaction bytes (cancel all orders on one market)
pub fn create_cancel_market_orders_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
market_id: u16,
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::CancelMarketOrders {
market_id: MarketId(market_id),
sub_account_index: None,
},
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Cancel All Orders
Cancels every open order across all markets.
#![allow(unused)]
fn main() {
/// build signed CancelAllOrders transaction bytes (cancel all orders on all markets)
pub fn create_cancel_all_order_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::CancelAllOrders {
sub_account_index: None,
},
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Replace Orders
Replace uses PlaceOrders with replace: true. This atomically cancels all existing orders on the market and
places the new set in one transaction. Useful for market makers who want to refresh their entire quote set.
#![allow(unused)]
fn main() {
/// build signed PlaceOrders transaction bytes with replace=true
///
/// replaces all existing orders on the market with the new set atomically
pub fn create_replace_order_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
market_id: u16,
orders: Vec<NewOrderArgs>,
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::PlaceOrders(PlaceOrdersCall {
market_id: MarketId(market_id),
orders,
replace: true,
sub_account_index: None,
}),
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Amend Orders
Amend atomically cancels specific orders by ID and places new ones. Unlike replace, it targets individual orders rather
than wiping the entire market. Each AmendOrderArg pairs a cancel (by order_id or client_order_id) with a new
order placement.
#![allow(unused)]
fn main() {
/// build signed AmendOrders transaction bytes
///
/// atomically cancels specific orders and places new ones in a single transaction
pub fn create_amend_order_bytes(
keypair: &SigningKey,
chain_id: u64,
chain_hash: &[u8; 32],
market_id: u16,
orders: Vec<AmendOrderArgs>,
) -> Vec<u8> {
let unsigned = UnsignedTransaction {
runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
ExchangeUserAction::AmendOrders(AmendOrdersCall {
market_id: MarketId(market_id),
orders,
sub_account_index: None,
}),
),)),
uniqueness: UniquenessData::Generation(timestamp_us()),
details: default_tx_details(chain_id),
};
sign_transaction(keypair, chain_hash, unsigned)
}
}
Order Management Comparison
| Scope | Atomic | Use case | Keeps queue priority | |
|---|---|---|---|---|
| Cancel + Place | Per order | No | Simple workflows | No |
| Amend | Per order | Yes | Adjusting price/size | No |
| Replace | Entire market | Yes | Refreshing full quote set | No |
Demo
fn main() {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use ed25519_dalek::SigningKey;
// generate a random keypair for demo
let keypair = SigningKey::generate(&mut rand::thread_rng());
let chain_id: u64 = 1;
let chain_hash: [u8; 32] = [0u8; 32]; // replace with real chain_hash from exchangeInfo
// place a limit buy
let order = NewOrderArgs {
price: to_surrogate(parse_decimal("50000.0")),
size: to_surrogate(parse_decimal("0.001")),
side: Side::Bid,
order_type: OrderType::Limit,
reduce_only: false,
client_order_id: Some(ClientOrderId(1)),
pending_tpsl_pair: None,
};
let place_bytes = create_place_order_bytes(&keypair, chain_id, &chain_hash, 0, vec![order]);
println!(
"place order tx ({} bytes): {}",
place_bytes.len(),
BASE64.encode(&place_bytes)
);
// cancel by order id
let cancel = CancelOrderArgs {
order_id: Some(OrderId(12345)),
client_order_id: None,
};
let cancel_bytes = create_cancel_order_bytes(&keypair, chain_id, &chain_hash, 0, vec![cancel]);
println!(
"cancel order tx ({} bytes): {}",
cancel_bytes.len(),
BASE64.encode(&cancel_bytes)
);
// cancel all orders on market 0
let cancel_market_bytes =
create_cancel_market_orders_bytes(&keypair, chain_id, &chain_hash, 0);
println!(
"cancel market orders tx ({} bytes): {}",
cancel_market_bytes.len(),
BASE64.encode(&cancel_market_bytes)
);
// cancel all orders across all markets
let cancel_all_bytes = create_cancel_all_order_bytes(&keypair, chain_id, &chain_hash);
println!(
"cancel all tx ({} bytes): {}",
cancel_all_bytes.len(),
BASE64.encode(&cancel_all_bytes)
);
// replace all orders on a market — cancels all existing, places new set atomically
let replacement = NewOrderArgs {
price: to_surrogate(parse_decimal("51000.0")),
size: to_surrogate(parse_decimal("0.002")),
side: Side::Bid,
order_type: OrderType::Limit,
reduce_only: false,
client_order_id: Some(ClientOrderId(2)),
pending_tpsl_pair: None,
};
let replace_bytes =
create_replace_order_bytes(&keypair, chain_id, &chain_hash, 0, vec![replacement]);
println!(
"replace orders tx ({} bytes): {}",
replace_bytes.len(),
BASE64.encode(&replace_bytes)
);
// amend a specific order — cancel by id and place a new one atomically
let amend = AmendOrderArgs {
cancel: CancelOrderArgs {
order_id: Some(OrderId(12345)),
client_order_id: None,
},
place: NewOrderArgs {
price: to_surrogate(parse_decimal("50500.0")),
size: to_surrogate(parse_decimal("0.0015")),
side: Side::Bid,
order_type: OrderType::Limit,
reduce_only: false,
client_order_id: Some(ClientOrderId(3)),
pending_tpsl_pair: None,
},
};
let amend_bytes = create_amend_order_bytes(&keypair, chain_id, &chain_hash, 0, vec![amend]);
println!(
"amend orders tx ({} bytes): {}",
amend_bytes.len(),
BASE64.encode(&amend_bytes)
);
// submit either via:
// POST /tx/submit { "body": "<base64>" }
// WS order.place { "method": "order.place", "params": { "tx": "<base64>" }, "id": 1 }
// WS order.cancel { "method": "order.cancel", "params": { "tx": "<base64>" }, "id": 2 }
// WS order.cancelAll { "method": "order.cancelAll", "params": { "tx": "<base64>" }, "id": 3 }
}
Submitting
Base64-encode the borsh-serialized bytes, then submit via REST or WebSocket:
POST /tx/submit
Content-Type: application/json
{ "body": "<base64>" }
{
"method": "order.place",
"params": {
"tx": "<base64>"
},
"id": 1
}
Use order.cancel for CancelOrders and order.cancelAll for CancelAllOrders.
Transaction Fields
Every transaction submitted to the Bullet exchange is wrapped in an UnsignedTransaction containing three components:
the call message, a uniqueness value, and transaction details. This guide covers the uniqueness and details fields.
Uniqueness
The UniquenessData field prevents transaction replay. Bullet supports two modes:
#![allow(unused)]
fn main() {
enum UniquenessData {
Nonce(u64) = 0,
Generation(u64) = 1,
}
}
Generation mode (recommended)
The uniqueness value can be whatever you want — it just needs to be unique per user within a block window (~5-6 seconds). We recommend using microsecond timestamps.
- No need to track or increment a counter
- Multiple transactions can be in-flight concurrently
Best practice: use SystemTime::now() in microseconds. Avoid reusing values — if you submit multiple
transactions in a tight loop, ensure each is distinct.
Nonce mode
Use Nonce(u64) for strict sequential ordering. The nonce must be exactly current_nonce + 1 for the credential.
This mode is simpler but only allows one in-flight transaction at a time.
TxDetails
#![allow(unused)]
fn main() {
struct TxDetails {
max_priority_fee_bips: u64,
max_fee: Amount,
gas_limit: Option<[u64; 2]>,
chain_id: u64,
}
}
These fields are required in every transaction. We recommend using the values below.
| Field | Type | Description |
|---|---|---|
chain_id | u64 | Chain identifier — fetch from GET /fapi/v1/exchangeInfo → chainInfo.chainId |
max_fee | Amount(u128) | Maximum fee the sender is willing to pay |
gas_limit | Option<[u64; 2]> | 2D gas limit: [compute, storage]. None defaults to block limit |
max_priority_fee_bips | u64 | Priority fee tip in basis points |
Gas model
Bullet uses a 2D gas model with separate compute and storage dimensions. The gas_limit field caps how much of
each resource a transaction may consume.
Paymaster
Gas fees are covered by a paymaster — users do not pay gas out of pocket. The fields are still required for transaction validity.
Recommended Values
| Field | Recommended value | Notes |
|---|---|---|
uniqueness | Generation(timestamp_us()) | Microsecond timestamp |
max_priority_fee_bips | 0 | |
max_fee | 1 << 48 | |
gas_limit | None | Defaults to block limit. Use [5_000_000, 5_000_000] for order placement or [500_000, 500_000] for simpler ops if you want explicit caps |
chain_id | from exchangeInfo | Must match the chain you are submitting to |
See the default_tx_details helper for a ready-to-use implementation.
Decimal Encoding
The Bullet exchange uses rust_decimal::Decimal (referred to as RustDecimal) for prices and sizes. This page explains
how to
encode a fixed-point integer (i64 value + u8 scale) into the 128-bit decimal layout used for borsh
serialization.
Layout
A RustDecimal has four u32 fields:
| field | bits | description |
|---|---|---|
flags | [31] sign, [23:16] scale | sign bit and decimal exponent |
hi | [95:64] of mantissa | high 32 bits (zero when abs value fits in u64) |
lo | [31:0] of mantissa | low 32 bits of the 96-bit mantissa |
mid | [63:32] of mantissa | middle 32 bits |
The mantissa is an unsigned 96-bit integer. The represented value is:
(-1)^sign * mantissa / 10^scale
For borsh serialization the fields are ordered as: flags, hi, lo, mid.
Converting from fixed-point to RustDecimal
If your system represents values as i64 with a known scale (e.g. N * 10^-scale), the
conversion is straightforward — the absolute value of the i64 is the mantissa, and the scale
goes into the flags field.
C++
// borsh wire layout: flags, hi, lo, mid
struct RustDecimal {
uint32_t flags;
uint32_t hi;
uint32_t lo;
uint32_t mid;
};
/// convert a fixed-point i64 with the given scale to RustDecimal
/// e.g. from_fixed(123456789, 8) encodes 1.23456789
RustDecimal from_fixed(int64_t value, uint8_t scale) {
bool negative = value < 0;
uint64_t abs_value = negative ? -static_cast<uint64_t>(value)
: static_cast<uint64_t>(value);
return RustDecimal{
.flags = (static_cast<uint32_t>(scale) << 16)
| (negative ? 0x80000000u : 0u),
.hi = 0,
.lo = static_cast<uint32_t>(abs_value),
.mid = static_cast<uint32_t>(abs_value >> 32),
};
}
Python
# borsh wire layout: flags, hi, lo, mid
@dataclass
class RustDecimal:
flags: int
hi: int
lo: int
mid: int
def from_fixed(value: int, scale: int) -> RustDecimal:
"""convert a fixed-point int with the given scale to RustDecimal
e.g. from_fixed(123456789, 8) encodes 1.23456789"""
negative = value < 0
abs_value = abs(value)
flags = (scale << 16) | (0x80000000 if negative else 0)
lo = abs_value & 0xFFFFFFFF
mid = (abs_value >> 32) & 0xFFFFFFFF
hi = 0
return RustDecimal(flags, hi, lo, mid)
JavaScript
// convert a fixed-point bigint with the given scale to RustDecimal fields
// borsh wire layout: flags, hi, lo, mid
// e.g. fromFixed(123456789n, 8) encodes 1.23456789
function fromFixed(value, scale) {
const negative = value < 0n;
const absValue = negative ? -value : value;
const flags = (scale << 16) | (negative ? 0x80000000 : 0);
const lo = Number(absValue & 0xFFFFFFFFn);
const mid = Number((absValue >> 32n) & 0xFFFFFFFFn);
const hi = 0;
return { flags: flags >>> 0, hi, lo, mid };
}
Rust
The rust_decimal crate handles this natively — parse from string or construct via
Decimal::from_parts(lo, mid, hi, negative, scale).
Examples
| input | scale | decimal value | flags | hi | lo | mid |
|---|---|---|---|---|---|---|
100000000 | 8 | 1.00000000 | 0x00080000 | 0x00000000 | 0x05F5E100 | 0x00000000 |
-50000000 | 8 | -0.50000000 | 0x80080000 | 0x00000000 | 0x02FAF080 | 0x00000000 |
123456789012 | 8 | 1234.56789012 | 0x00080000 | 0x00000000 | 0xBE991A14 | 0x0000001C |
Consistency Guarantees
Market data streaming
When streaming market data via a websocket topic, the following guarantees are provided:
Snapshots:
When subscribing to a market data topic, the first message received will be a snapshot of the current state of the order book.
This can be recognized by the mt field which will be set to "s" (snapshot). Subsequent messages will have mt set
to "u" (update).
Backend disconnections:
If the backend experiences connectivity issues with the exchange rollup/matching engine, on recovery a snapshot will be sent to ensure the client has a consistent view of the order book.
Ordering
Depth updates are published with sequence identifiers, which allows clients to detect if any updates were missed.
u-> ‘last update id’ - a monotonic sequence number incremented with each new update.pu-> ‘previous update id’ - theuvalue from the previous message. On the first snapshot,puis0.
By comparing the pu of the current message with the u of the last received message, clients can determine if any
updates were missed. If there is a gap in the sequence, unsubscribe and resubscribe to get a fresh snapshot of the order
book to ensure consistency.
Periodic snapshots:
In addition to the initial snapshot, the server periodically sends full snapshots (approximately every few seconds). This acts as a self-healing mechanism — even if a client misses a delta update, the next periodic snapshot will bring its local state back in sync without needing to resubscribe.
Any message with "mt":"s" is a full snapshot and should replace the client’s local state entirely. Snapshots are sent
on initial subscription, periodically, and on recovery from backend connectivity issues. After a snapshot, sequence
numbering may reset — clients should not validate pu continuity across snapshots.
SDKs
Rust
github.com/bulletxyz/bullet-rust-sdk
Type-safe client generated from the trading API’s OpenAPI spec. Includes REST, WebSocket, transaction signing, and keypair management.
[dependencies]
bullet-rust-sdk = { git = "https://github.com/bulletxyz/bullet-rust-sdk.git" }
Features
- auto-generated REST client from OpenAPI spec
- WebSocket client with type-safe topic subscriptions
- Ed25519 keypair generation and management
- transaction building and signing
- cross-platform (native + WASM)
Changelog
2026-03-18
REST API
- add:
GET /fapi/v1/allOrders— query order history (filled, cancelled, expired) by address, with optional symbol, time range, and orderId filters
2026-03-17
REST API
- add:
GET /fapi/v1/userTrades— query account fill history by address, with optional symbol and time range filters
2026-03-16
SDK
- add: Rust SDK — type-safe client with REST, WebSocket, transaction signing, and keypair management
Documentation
- add: connectivity page — environment hosts and endpoint URLs
2026-03-13
Documentation
- add: transaction fields guide — documents all order fields, time-in-force options, and self-trade prevention modes
- add: amend order example in transaction signing guide
- add: comparison of amend vs cancel-replace order modification strategies
2026-03-12
REST API
- fix: OpenAPI schema now uses correct unsigned integer types for IDs and quantities
- fix: removed internal metrics endpoint from public OpenAPI spec
2026-03-12
Documentation
- add: account setup guide — sign in, deposit, get a signing key, start trading
- add: delegate accounts guide — generate a keypair, register via webapp, trade with scoped and revocable access
2026-03-10
Documentation
- fix: decimal encoding guide — simplified examples to encode
RustDecimaldirectly, removing redundantSurrogateDecimalwrapper (no wire format change)
2026-03-05
WebSocket
- add: consistency guarantees page — documents snapshot delivery, backend reconnection behavior, and message ordering
- change: idle timeout disconnect reason is now
idle_timeout— server disconnects if no valid message is received within 60 seconds of connecting
2026-02-20
Documentation
- add: CancelAllOrders and CancelMarketOrders transaction signing examples
- add: decimal encoding guide with C++, Python, and JavaScript examples
- add: guides landing page and sidebar section
- fix: removed internal terminology from public docs
2026-02-19
Documentation
- fix: OrderResult field table now matches actual response schema (
results.tx_id,results.status,results.order_ids,results.client_order_ids) - fix: transaction signing guide uses correct camelCase field names (
chainInfo.chainId,chainHash) matching REST API response - fix: ListSubscriptions and Error examples now use correct 16-digit microsecond timestamps
- add: documented
order.amend/order.modifyWebSocket method - add: documented
order.cancelAllWebSocket method - fix: topic tables now list all accepted aliases (
SYMBOL@liquidations,!liquidations,!ticker,!markPrice, etc.) - fix:
@liquidationsis now the primary topic name,@forceOrderas alias - note: kline topics marked as coming soon
- change: WebSocket method names default to lowercase (
order.place,subscribe), uppercase accepted as alias
2026-02-18
Initial Release
- WebSocket API specification with Binance FAPI-compatible format
- connection lifecycle (status, keepalive, disconnect reasons)
- subscription streams (subscribe, unsubscribe, list_subscriptions)
- request-response operations (order.place, order.cancel)
- market data streams (depthUpdate, aggTrade, bookTicker, markPrice, forceOrder, orderTradeUpdate)
- topic format reference and error codes
- Transaction signing guide with Rust reference implementation
- borsh type definitions matching exchange schema
- ed25519 signing flow
- PlaceOrders and CancelOrders examples
- REST and WebSocket submission
- REST API docs (Swagger/OpenAPI) served at
/docs/rest