Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 identifier
  • chainHash — 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

ScopeAtomicUse caseKeeps queue priority
Cancel + PlacePer orderNoSimple workflowsNo
AmendPer orderYesAdjusting price/sizeNo
ReplaceEntire marketYesRefreshing full quote setNo

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.