Skip to main content
All transactions submitted to POST /order require Ed25519 signatures for authentication.

JSON vs Binary Formats

The API accepts transactions as JSON with base58-encoded cryptographic fields:
FieldJSON FormatExample
accountbase58 string"9J8TUdEWrrcADK913r1Cs7DdqX63VdVU88imfDzT1ypt"
signerbase58 string"9J8TUdEWrrcADK913r1Cs7DdqX63VdVU88imfDzT1ypt"
signaturebase58 string"5j7sVt3k2YxPqH4w..."
Order IDs (oid)base58 string"Fpa3oVuL3UzjNANAMZZdmrn6D1Zhk83GmBuJpuAWG51F"
Pubkeys (u, a, target)base58 string"8DmyR3yJhpQHBqgSGua4c69PZ9ZMeaJddTumUdmTx7a"
For signing, construct the canonical BULK signing message (protocol-specific encoding), sign it with Ed25519, then base58-encode the 64-byte signature for the JSON payload.

What Gets Signed

The signature is computed over:
binary_message = bincode_serialize(actions) + nonce_le_u64 + account_pubkey_bytes
signature = ed25519_sign(binary_message, signer_secret_key)
json_signature = bs58_encode(signature)
Notes:
  • signer is required in the JSON payload but is not part of the signed message bytes.
  • If signer != account, the signer must be an authorized agent for that account.
  • account_pubkey_bytes means raw 32 bytes (bs58.decode(account)).
  • The signature field itself is not included in what gets signed.
Nonce: Use a unique value for replay protection (e.g. timestamp in nanoseconds: BigInt(Date.now()) * 1_000_000n or an incrementing counter).

Transaction Structure

All signed requests to POST /order use this unified envelope:
{
  "actions": [Action, ...],
  "nonce": 1704067200000000000,
  "account": "FuueqefENiGEW6uMqZQgmwjzgpnb85EgUcZa5Em4PQh7",
  "signer": "FuueqefENiGEW6uMqZQgmwjzgpnb85EgUcZa5Em4PQh7",
  "signature": "5j7sVt3k2YxPqH4w..."
}
FieldDescription
actionsArray of actions to execute atomically (see Place Order and related pages).
nonceUnique integer for replay protection (use timestamp in nanoseconds or incrementing counter).
accountAccount public key (base58) — the account performing the action.
signerSigner public key (base58) — who is signing (usually same as account, or authorized agent).
signatureEd25519 signature (base58).

Action Encoding

bincode_serialize(actions) must match the canonical BULK protocol encoding. On the wire, wincode and bincode are the same (bincode-compatible; see wincode for the Rust crate). For production use, use the official signing library so the signed bytes match the server. For order actions (l / m), px and sz use fixed-point binary encoding:
scaled = round(value * 1e8)   // serialized as u64 in binary form

Official Signing Library (bulk-keychain)

The recommended way to sign transactions is the official bulk-keychain library. One Rust core with bindings for:
EnvironmentPackageInstall
Node.jsbulk-keychainnpm install bulk-keychain
Browser (Web)bulk-keychain-wasmnpm install bulk-keychain-wasm
Pythonbulk-keychainpip install bulk-keychain
Rustbulk-keychaincargo add bulk-keychain
Repository: github.com/Bulk-trade/bulk-keychain It handles canonical action encoding, signing (single order, batch, grouped/bracket), agent wallet, faucet, and external wallet (prepare/finalize) flows. For order ID computation (deterministic, no client order ID required), see Order IDs.

Reference: Serialization Format (wincode)

The following describes a logical binary format used in some implementations. The authoritative signing message is bincode_serialize(actions) + nonce_le_u64 + account_pubkey_bytes (no signer); the BULK protocol and bulk-keychain use the canonical encoding.
TypeEncoding
Enum variantu32 discriminant (0, 1, 2…)
Pubkey/HashRaw 32 bytes (decoded from base58)
SignatureRaw 64 bytes
Stringu64 length prefix + UTF-8 bytes
Option<T>1 byte (0=None, 1=Some) + T if Some
Vec<T>u64 count + elements
bool1 byte (0 or 1)
u64/f648 bytes little-endian
u324 bytes little-endian

Enum Discriminant Mappings

const ACTION_CODES = {
  order: 0,
  oracle: 1,
  faucet: 2,
  updateUserSettings: 3,
  agentWalletCreation: 4,
};

Signing Example (canonical message)

The signed message must be exactly:
// Prescribed order: bincode(actions) + nonce_le_u64 + account_pubkey_bytes (no signer)
const actionsBytes = canonicalBincodeSerialize(actions);  // Use bulk-keychain or protocol encoding
const nonceBytes = toU64LE(nonce);
const accountBytes = bs58.decode(account);  // 32 bytes

const message = concat(actionsBytes, nonceBytes, accountBytes);
const signature = bs58.encode(nacl.sign.detached(message, secretKey));

const tx = { actions, nonce, account, signer, signature };

Working Example (reference implementation)

The prescribed signing message is bincode_serialize(actions) + nonce_le_u64 + account_pubkey_bytes. Do not include signer in the signed bytes. The reference code below uses a logical action representation; for production use the official signing library (bulk-keychain) so encoding matches the server.
Important: Serialization must match the canonical BULK protocol encoding exactly. This implementation is for reference only; prefer the official bulk-keychain library.

Install Dependencies

pnpm install tweetnacl bs58
# or
yarn add tweetnacl bs58

Implementation

import * as nacl from 'tweetnacl';
import bs58 from 'bs58';

// ============================================================================
// Enum Discriminant Mappings
// ============================================================================

const ACTION_CODES: Record<string, number> = {
  order: 0,
  oracle: 1,
  faucet: 2,
  updateUserSettings: 3,
  agentWalletCreation: 4,
};

const ORDER_ITEM_CODES: Record<string, number> = {
  order: 0,
  cancel: 1,
  cancelAll: 2,
};

const TIME_IN_FORCE_CODES: Record<string, number> = {
  GTC: 0,
  IOC: 1,
  ALO: 2,
};

const ORDER_TYPE_CODES: Record<string, number> = {
  limit: 0,
  trigger: 1,
};

// ============================================================================
// Primitive Writers
// ============================================================================

function writeU32(value: number): Uint8Array {
  const buf = new Uint8Array(4);
  new DataView(buf.buffer).setUint32(0, value, true); // little-endian
  return buf;
}

function writeU64(value: number | bigint): Uint8Array {
  const buf = new Uint8Array(8);
  new DataView(buf.buffer).setBigUint64(0, BigInt(value), true); // little-endian
  return buf;
}

function writeBool(value: boolean): Uint8Array {
  return new Uint8Array([value ? 1 : 0]);
}

function writeString(str: string): Uint8Array {
  const bytes = new TextEncoder().encode(str);
  return concatBytes(writeU64(bytes.length), bytes);
}

function writeF64(value: number): Uint8Array {
  const buf = new Uint8Array(8);
  new DataView(buf.buffer).setFloat64(0, value, true); // little-endian
  return buf;
}

function concatBytes(...arrays: Uint8Array[]): Uint8Array {
  const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
  const result = new Uint8Array(totalLength);
  let offset = 0;
  for (const arr of arrays) {
    result.set(arr, offset);
    offset += arr.length;
  }
  return result;
}

// ============================================================================
// Validation Helpers
// ============================================================================

function decodeAndValidateKey(key: string): Uint8Array {
  const bytes = bs58.decode(key);
  if (bytes.length !== 32) {
    throw new Error(`Key must be 32 bytes, got ${bytes.length}`);
  }
  return bytes;
}

function decodeAndValidateHash(hash: string): Uint8Array {
  const bytes = bs58.decode(hash);
  if (bytes.length !== 32) {
    throw new Error(`Hash must be 32 bytes, got ${bytes.length}`);
  }
  return bytes;
}

// ============================================================================
// Order Serialization
// ============================================================================

function serializeOrderItem(orderWrapper: any): Uint8Array {
  // Get the order item type (order, cancel, or cancelAll)
  const itemType = Object.keys(orderWrapper)[0];
  if (!itemType || !(itemType in ORDER_ITEM_CODES)) {
    throw new Error(`Invalid order item type: ${itemType}`);
  }

    const parts: Uint8Array[] = [];
    
  // Write order item discriminant (u32)
  parts.push(writeU32(ORDER_ITEM_CODES[itemType]));

  const data = orderWrapper[itemType];

  if (itemType === 'order') {
    // Order: asset, is_buy, price, size, reduce_only, order_type, client_id
    parts.push(writeString(data.c));           // asset
    parts.push(writeBool(data.b));             // is_buy
    parts.push(writeF64(data.px));             // price
    parts.push(writeF64(data.sz));             // size
    parts.push(writeBool(data.r));             // reduce_only

    // Order type (limit or trigger)
    if (data.t.limit) {
      parts.push(writeU32(ORDER_TYPE_CODES.limit));
      parts.push(writeU32(TIME_IN_FORCE_CODES[data.t.limit.tif] || 0));
    } else if (data.t.trigger) {
      parts.push(writeU32(ORDER_TYPE_CODES.trigger));
      parts.push(writeBool(data.t.trigger.is_market));
      parts.push(writeF64(data.t.trigger.triggerPx));
    } else {
      throw new Error('Order must have either limit or trigger type');
    }

    // client_id: Option<Hash> - 1 byte discriminant + 32 bytes if Some
    if (data.cloid) {
      parts.push(writeBool(true));
      parts.push(decodeAndValidateHash(data.cloid)); // Raw 32 bytes
    } else {
      parts.push(writeBool(false));
    }

  } else if (itemType === 'cancel') {
    // Cancel: asset, oid (Hash as raw 32 bytes)
    parts.push(writeString(data.c));           // asset
    parts.push(decodeAndValidateHash(data.oid)); // oid - raw 32 bytes

  } else if (itemType === 'cancelAll') {
    // CancelAll: assets (Vec<String>)
    const assets = data.c || [];
    parts.push(writeU64(assets.length));
    for (const asset of assets) {
      parts.push(writeString(asset));
    }
  }

  return concatBytes(...parts);
}

function serializeOrders(orders: any[]): Uint8Array {
  const parts: Uint8Array[] = [];

  // Write order count (u64)
  parts.push(writeU64(orders.length));

  // Serialize each order item
  for (const order of orders) {
    parts.push(serializeOrderItem(order));
  }

  return concatBytes(...parts);
}

// ============================================================================
// Action Serialization
// ============================================================================

function serializeFaucet(faucet: any): Uint8Array {
  const parts: Uint8Array[] = [];

  // user: Pubkey (raw 32 bytes)
  parts.push(decodeAndValidateKey(faucet.u));

  // amount: Option<f64>
  if (faucet.amount !== undefined && faucet.amount !== null) {
    parts.push(writeBool(true));
    parts.push(writeF64(faucet.amount));
  } else {
    parts.push(writeBool(false));
  }

  return concatBytes(...parts);
}

function serializeAgentWalletCreation(agent: any): Uint8Array {
  return concatBytes(
    decodeAndValidateKey(agent.a),  // agent: Pubkey (raw 32 bytes)
    writeBool(agent.d)               // delete: bool
  );
}

function serializeUpdateUserSettings(settings: any): Uint8Array {
  const parts: Uint8Array[] = [];

  const leverageMap = settings.m || [];
  parts.push(writeU64(leverageMap.length));

  for (const [symbol, leverage] of leverageMap) {
    parts.push(writeString(symbol));
    parts.push(writeF64(leverage));
  }

  return concatBytes(...parts);
}

function serializeOracle(oracles: any[]): Uint8Array {
  const parts: Uint8Array[] = [];

  parts.push(writeU64(oracles.length));

  for (const oracle of oracles) {
    parts.push(writeU64(oracle.t));      // timestamp
    parts.push(writeString(oracle.c));   // asset
    parts.push(writeF64(oracle.px));     // price
  }

  return concatBytes(...parts);
}

// ============================================================================
// Main Transaction Serialization
// ============================================================================

/**
 * Serialize transaction using wincode format for signing.
 *
 * Format: action_discriminant(u32) + action_data + nonce(u64) + account(32) + signer(32)
 */
export function serializeTransaction(
  action: any,
  account: string,
  signer: string
): Uint8Array {
  const actionType = action.type || '';

  if (!(actionType in ACTION_CODES)) {
    throw new Error(`Invalid action type: ${actionType}`);
  }

  const parts: Uint8Array[] = [];

  // 1. Action discriminant (u32)
  parts.push(writeU32(ACTION_CODES[actionType]));

  // 2. Action-specific data
  switch (actionType) {
    case 'order':
      parts.push(serializeOrders(action.orders || []));
      break;
    case 'oracle':
      parts.push(serializeOracle(action.oracles || []));
      break;
    case 'faucet':
      parts.push(serializeFaucet(action.faucet || {}));
      break;
    case 'updateUserSettings':
      parts.push(serializeUpdateUserSettings(action.settings || {}));
      break;
    case 'agentWalletCreation':
      parts.push(serializeAgentWalletCreation(action.agent || {}));
      break;
  }

  // 3. Nonce (u64)
  parts.push(writeU64(action.nonce));

  // 4. Account (32 bytes)
  parts.push(decodeAndValidateKey(account));

  // 5. Signer (32 bytes)
  parts.push(decodeAndValidateKey(signer));

  return concatBytes(...parts);
}

/**
 * Sign a transaction action for the exchange
 */
export function signTransaction(
    secretKey: Uint8Array,
    action: any,
    account: string,
    signer: string
): string {
  // Serialize the transaction using wincode format
    const message = serializeTransaction(action, account, signer);
    
    // Sign with Ed25519
    const signature = nacl.sign.detached(message, secretKey);
    
    // Encode as base58
    return bs58.encode(signature);
}

Binary Layout Reference

The prescribed signing message is bincode_serialize(actions) + nonce_le_u64 + account_pubkey_bytes (no signer). The layouts below describe a logical equivalent; the BULK protocol defines the canonical bincode for actions.

Order Action Binary Layout (logical equivalent)

[4 bytes]  Action discriminant (0 = order)
[8 bytes]  Order count (u64)

For each order item:
  [4 bytes]  Order item discriminant (0=order, 1=cancel, 2=cancelAll)

  If order (0):
    [8 bytes + N]  Asset string (u64 length + UTF-8 bytes)
    [1 byte]       is_buy (bool)
    [8 bytes]      price (f64)
    [8 bytes]      size (f64)
    [1 byte]       reduce_only (bool)
    [4 bytes]      Order type discriminant (0=limit, 1=trigger)
    If limit:
      [4 bytes]    Time in force (0=GTC, 1=IOC, 2=ALO)
    If trigger:
      [1 byte]     is_market (bool)
      [8 bytes]    trigger_price (f64)
    [1 byte]       client_id Option (0=None, 1=Some)
    If Some:
      [32 bytes]   client_id Hash (raw bytes, NOT base58 string!)

  If cancel (1):
    [8 bytes + N]  Asset string
    [32 bytes]     Order ID Hash (raw bytes)

  If cancelAll (2):
    [8 bytes]      Asset count (u64)
    For each asset:
      [8 bytes + N]  Asset string

[8 bytes]  Nonce (u64)
[32 bytes] Account pubkey (raw bytes)
(Prescribed message does not include signer.)

Faucet Action Binary Layout

[4 bytes]  Action discriminant (2 = faucet)
[32 bytes] User pubkey (raw bytes, NOT base58 string!)
[1 byte]   Amount Option (0=None, 1=Some)
If Some:
  [8 bytes] Amount (f64)
[8 bytes]  Nonce (u64)
[32 bytes] Account pubkey (raw bytes)

Agent Wallet Action Binary Layout

[4 bytes]  Action discriminant (4 = agentWalletCreation)
[32 bytes] Agent pubkey (raw bytes)
[1 byte]   Delete flag (bool)
[8 bytes]  Nonce (u64)
[32 bytes] Account pubkey (raw bytes)

Update User Settings Binary Layout

[4 bytes]  Action discriminant (3 = updateUserSettings)
[8 bytes]  Leverage map count (u64)
For each entry:
  [8 bytes + N]  Symbol string (u64 length + UTF-8 bytes)
  [8 bytes]      Leverage (f64)
[8 bytes]  Nonce (u64)
[32 bytes] Account pubkey (raw bytes)

Account vs Signer

The transaction includes two separate public key fields:
  • account: The account being traded (whose positions/orders are affected)
  • signer: Who’s signing the transaction (usually same as account, or authorized agent)

Same Account and Signer

Most common case: you’re trading your own account.
const nonce = BigInt(Date.now()) * 1_000_000n;

const transaction = {
  actions: [{ l: { c: "BTC-USD", b: true, px: 100000.0, sz: 0.1, tif: "GTC", r: false } }],
  nonce: Number(nonce),
  account: myPublicKey,    // Your account
  signer: myPublicKey,     // You're signing
  signature: "..."         // Ed25519 base58
};

Agent Wallet (Different Signer)

Agent wallet trading on behalf of user:
const nonce = BigInt(Date.now()) * 1_000_000n;

const transaction = {
  actions: [{ l: { c: "BTC-USD", b: true, px: 100000.0, sz: 0.1, tif: "GTC", r: false } }],
  nonce: Number(nonce),
  account: userPublicKey,   // User's account being traded
  signer: agentPublicKey,   // Agent signing (must be pre-authorized)
  signature: "..."
};
Agent Wallets: If signer != account, the signer must be pre-authorized via the Manage Agent Wallet endpoint first.

Implementation Notes

Official signing library: bulk-keychain provides signing for Node.js, browser (WASM), Python, and Rust with the correct protocol encoding. For production use, prefer bulk-keychain over manual implementation.

Common Issues

The signed bytes must be exactly bincode_serialize(actions) + nonce_le_u64 + account_pubkey_bytes. Do not include signer or signature in the signed message.
If signer != account, the agent must be pre-authorized first (e.g. via Manage Agent Wallet).
Account must be funded first via a faucet action (e.g. Request Faucet on testnet).
Each nonce can only be used once. Use nanosecond timestamps or incrementing counters.