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.