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.

The Rust SDK does this for you. The Rust code below is a reference implementation; the same steps apply in any language:

1. Build call message           (e.g. PlaceOrdersCall, CancelAllOrders)
2. Wrap in UnsignedTransaction  (call + uniqueness + tx details)
3. Sign                         borsh(SigningPayload::V0 { 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 the chain hash and available markets:

GET /fapi/v1/exchangeInfo

From the response, extract:

  • chainHash — 32-byte hex string, decode to [u8; 32] (strip 0x prefix if present). It is both the signing domain separator and the source of TxDetails.chain_hash_fragment (its first 8 bytes as a little-endian u64); chainInfo.chainId is no longer part of a current-format transaction (only the pre-fork encoding accepted during the transition window below still carries it)
  • symbols — array of available markets; each entry contains marketId (u16) and symbol (e.g. "BTC-USD")

Use the symbols array to map between human-readable symbol names and the market_id values required by all order calls.

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.23"

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() {
// 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]>,
    /// first 8 bytes of the chain hash as a little-endian u64; see `chain_hash_fragment`
    pub chain_hash_fragment: u64,
}

#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum UniquenessData {
    Nonce(u64) = 0,
    Generation(u64) = 1,
    Window(u64) = 2,
}

#[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,
    /// account to execute as; `None` executes as the signer's own address
    pub address_override: Option<[u8; 32]>,
}

#[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,
}

/// what gets signed: the unsigned transaction plus the chain hash, behind a version byte
#[derive(Clone, BorshSerialize)]
pub struct SigningPayloadV0 {
    pub runtime_call: RuntimeCall,
    pub uniqueness: UniquenessData,
    pub details: TxDetails,
    pub address_override: Option<[u8; 32]>,
    pub chain_hash: [u8; 32],
}

#[derive(Clone, BorshSerialize)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum SigningPayload {
    V0(SigningPayloadV0) = 0,
}
}

For field reference (order types, TP/SL, sub-account index), see Order Types and Fields.

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_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time after unix epoch")
        .as_millis() as u64
}

/// the chain hash fragment every transaction commits to: the first 8 bytes of the
/// chain hash read as a little-endian u64
pub fn chain_hash_fragment(chain_hash: &[u8; 32]) -> u64 {
    u64::from_le_bytes(chain_hash[..8].try_into().expect("8 bytes"))
}

pub fn default_tx_details(chain_hash: &[u8; 32]) -> 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_hash_fragment: chain_hash_fragment(chain_hash),
    }
}
}

Signing

The signed bytes are the borsh-serialized SigningPayload::V0: a version byte (0), the unsigned transaction (call, uniqueness, details, address_override), and the 32-byte chain hash as a domain separator. The wire format wraps the same unsigned transaction with the signature and public key.

#![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> {
    // the signed bytes are the borsh-serialized signing payload: a version byte (0),
    // the unsigned transaction, then the chain hash as a domain separator
    let payload = SigningPayload::V0(SigningPayloadV0 {
        runtime_call: unsigned.runtime_call.clone(),
        uniqueness: unsigned.uniqueness.clone(),
        details: unsigned.details.clone(),
        address_override: unsigned.address_override,
        chain_hash: *chain_hash,
    });
    let msg = borsh::to_vec(&payload).expect("borsh serialize");

    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")
}
}

Transition from the previous format. Before the accounts hard fork, transactions carried chain_id in TxDetails, had no address_override, and were signed over borsh(unsigned_tx) ++ chain_hash with no version byte. Each environment can keep accepting that encoding for single-signer transactions below a configured cutoff height (ACCEPT_LEGACY_V0_TXS_UNTIL_HEIGHT); the cutoff ships disabled and is announced with the upgrade schedule, and above it only the format on this page is accepted. Switch as soon as you can.

Place Orders

#![allow(unused)]
fn main() {
/// build signed PlaceOrders transaction bytes
pub fn create_place_order_bytes(
    keypair: &SigningKey,
    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::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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_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::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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_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::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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_hash: &[u8; 32]) -> Vec<u8> {
    let unsigned = UnsignedTransaction {
        runtime_call: RuntimeCall::Exchange((ExchangeCallMessage::User(
            ExchangeUserAction::CancelAllOrders {
                sub_account_index: None,
            },
        ),)),
        uniqueness: UniquenessData::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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_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::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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_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::Window(timestamp_ms()),
        details: default_tx_details(chain_hash),
        address_override: None,
    };

    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;
    use base64::engine::general_purpose::STANDARD as BASE64;
    use ed25519_dalek::SigningKey;

    // generate a random keypair for demo
    let keypair = SigningKey::generate(&mut rand::thread_rng());
    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_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_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_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_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_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_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:

REST:

POST /tx/submit
Content-Type: application/json

{ "body": "<base64>" }

A DegenTrade call message also has its own route, POST /tx/submitDegen, with the same body and the same response. It accepts nothing else.

WebSocket:

{
  "method": "order.place",
  "params": {
    "tx": "<base64>"
  },
  "id": 1
}

tx is the base64-encoded borsh-serialized SignedTransaction bytes from the signing step above.

Transaction typeWebSocket method
PlaceOrdersorder.place
PlaceOrders (replace)order.place
CancelOrdersorder.cancel
CancelMarketOrdersorder.cancelAll
CancelAllOrdersorder.cancelAll
AmendOrdersorder.amend