Skip to content

Orderbook Complete Guide (Proxy Wallet)

This comprehensive guide walks you through the complete process of interacting with Probable Markets orderbook using a proxy wallet (Gnosis Safe). This step-by-step tutorial covers all prerequisites, setup, and the complete order placement flow.

Alternative Flow: If you prefer to use your EOA wallet directly without creating a proxy wallet, see the Orderbook EOA Guide for a simpler integration flow.

Overview

This guide provides a complete walkthrough for integrating with Probable Markets orderbook, starting from scratch with just an EOA wallet. You'll learn how to:

  1. Create a Proxy Wallet - Set up a Gnosis Safe smart contract wallet derived from your EOA
  2. Approve Tokens - Grant spending allowances for USDT and CTF tokens
  3. Deposit USDT - Transfer USDT to your proxy wallet
  4. Create API Key - Generate API credentials for authenticated requests
  5. Create and Sign Order - Build an EIP-712 signed order
  6. Submit Order - Send the order to the API with L2 authentication

Note: This guide references the implementation in clob-examples. For complete working code, see the clob-examples repository.


Prerequisites

  • An EOA wallet with:
    • BNB for transaction fees
    • USDT for trading (will be deposited to proxy wallet)
  • Access to a BSC RPC endpoint
  • Node.js/Bun environment with viem and @safe-global/protocol-kit installed

Contract Addresses

const PROXY_FACTORY_ADDRESS = '0xB99159aBF0bF59a512970586F38292f8b9029924';
const USDT_ADDRESS = '0x55d398326f99059fF775485246999027B3197955';
const CTF_TOKEN_ADDRESS = '0x364d05055614B506e2b9A287E4ac34167204cA83';
const CTF_EXCHANGE_ADDRESS = '0xF99F5367ce708c66F0860B77B4331301A5597c86';
const CHAIN_ID = 56; // BSC Mainnet

Step 1: Create Proxy Wallet

The proxy wallet is a Gnosis Safe smart contract wallet that acts as the maker for your orders. It's deterministically derived from your EOA address.

1.1 Check if Proxy Exists

First, check if a proxy wallet already exists for your EOA:

import { getContract } from 'viem';
import { PROXY_FACTORY_ADDRESS, PROXY_FACTORY_ABI } from './config';
 
async function checkProxyExists(userAddress: `0x${string}`) {
  const proxyFactory = getContract({
    address: PROXY_FACTORY_ADDRESS,
    abi: PROXY_FACTORY_ABI,
    client: publicClient,
  });
 
  const proxyAddress = await proxyFactory.read.computeProxyAddress([userAddress]);
  
  // Check if proxy is deployed
  const code = await publicClient.getBytecode({ address: proxyAddress });
  const exists = code !== undefined && code !== '0x';
 
  return { exists, address: proxyAddress };
}

1.2 Create Proxy Wallet

If the proxy doesn't exist, create it with an EIP-712 signature:

import { PROXY_FACTORY_ABI } from './config';
 
async function createProxyWallet(
  userAddress: `0x${string}`,
  walletClient: WalletClient
): Promise<`0x${string}`> {
  const proxyFactory = getContract({
    address: PROXY_FACTORY_ADDRESS,
    abi: PROXY_FACTORY_ABI,
    client: { public: publicClient, wallet: walletClient },
  });
 
  // Get computed proxy address
  const proxyAddress = await proxyFactory.read.computeProxyAddress([userAddress]);
 
  // Generate EIP-712 signature for CreateProxy
  const paymentToken = '0x0000000000000000000000000000000000000000';
  const payment = 0n;
  const paymentReceiver = '0x0000000000000000000000000000000000000000';
 
  const domain = {
    name: 'Probable Contract Proxy Factory',
    chainId: 56,
    verifyingContract: PROXY_FACTORY_ADDRESS,
  };
 
  const types = {
    CreateProxy: [
      { name: 'paymentToken', type: 'address' },
      { name: 'payment', type: 'uint256' },
      { name: 'paymentReceiver', type: 'address' },
    ],
  };
 
  const message = { paymentToken, payment, paymentReceiver };
  const signature = await walletClient.signTypedData({
    domain,
    types,
    primaryType: 'CreateProxy',
    message,
  });
 
  // Parse signature into v, r, s
  const r = `0x${signature.slice(2, 66)}` as `0x${string}`;
  const s = `0x${signature.slice(66, 130)}` as `0x${string}`;
  let v = parseInt(signature.slice(130, 132), 16);
  if (v < 27) v += 27;
 
  // Create proxy
  const hash = await proxyFactory.write.createProxy(
    [paymentToken, payment, paymentReceiver, { v, r, s }],
    { account: walletClient.account }
  );
 
  await publicClient.waitForTransactionReceipt({ hash });
  return proxyAddress;
}

Step 2: Approve Tokens

Before placing orders, you must approve token spending. The proxy wallet needs three approvals:

  1. USDT → CTF Token Contract (for splitting/merging positions)
  2. USDT → CTF Exchange (for trading)
  3. CTF Tokens → CTF Exchange (for trading)

2.1 Check Existing Approvals

import { maxUint256 } from 'viem';
import { ERC20_ABI, ERC1155_ABI } from './config';
 
async function checkApprovals(proxyAddress: `0x${string}`) {
  const [usdtForCTFToken, usdtForExchange, ctfTokenForExchange] = 
    await publicClient.multicall({
      contracts: [
        {
          address: USDT_ADDRESS,
          abi: ERC20_ABI,
          functionName: 'allowance',
          args: [proxyAddress, CTF_TOKEN_ADDRESS],
        },
        {
          address: USDT_ADDRESS,
          abi: ERC20_ABI,
          functionName: 'allowance',
          args: [proxyAddress, CTF_EXCHANGE_ADDRESS],
        },
        {
          address: CTF_TOKEN_ADDRESS,
          abi: ERC1155_ABI,
          functionName: 'isApprovedForAll',
          args: [proxyAddress, CTF_EXCHANGE_ADDRESS],
        },
      ],
    });
 
  return {
    needsUSDTForCTFToken: usdtForCTFToken.result < maxUint256,
    needsUSDTForExchange: usdtForExchange.result < maxUint256,
    needsCTFTokenForExchange: !ctfTokenForExchange.result,
  };
}

2.2 Execute Approvals via Safe

All approvals are executed through the proxy wallet (Gnosis Safe) in a single batch transaction:

import Safe from '@safe-global/protocol-kit';
import { encodeFunctionData } from 'viem';
 
async function approveTokens(
  proxyAddress: `0x${string}`,
  privateKey: `0x${string}`
) {
  const protocolKit = await Safe.init({
    provider: rpcUrl,
    signer: privateKey,
    safeAddress: proxyAddress,
  });
 
  const transactions = [];
 
  // USDT approval for CTF Token
  transactions.push({
    to: USDT_ADDRESS,
    data: encodeFunctionData({
      abi: ERC20_ABI,
      functionName: 'approve',
      args: [CTF_TOKEN_ADDRESS, maxUint256],
    }) as `0x${string}`,
    value: '0',
  });
 
  // USDT approval for Exchange
  transactions.push({
    to: USDT_ADDRESS,
    data: encodeFunctionData({
      abi: ERC20_ABI,
      functionName: 'approve',
      args: [CTF_EXCHANGE_ADDRESS, maxUint256],
    }) as `0x${string}`,
    value: '0',
  });
 
  // CTF Token approval for Exchange
  transactions.push({
    to: CTF_TOKEN_ADDRESS,
    data: encodeFunctionData({
      abi: ERC1155_ABI,
      functionName: 'setApprovalForAll',
      args: [CTF_EXCHANGE_ADDRESS, true],
    }) as `0x${string}`,
    value: '0',
  });
 
  const safeTransaction = await protocolKit.createTransaction({
    transactions,
  });
 
  const executeTxResponse = await protocolKit.executeTransaction(safeTransaction);
  await publicClient.waitForTransactionReceipt({ hash: executeTxResponse.hash });
}

Step 3: Deposit USDT to Proxy Wallet

After approvals, deposit USDT from your EOA to the proxy wallet:

async function depositUSDT(
  eoaAddress: `0x${string}`,
  proxyAddress: `0x${string}`,
  amount: bigint,
  walletClient: WalletClient
) {
  const hash = await walletClient.sendTransaction({
    to: USDT_ADDRESS,
    data: encodeFunctionData({
      abi: ERC20_ABI,
      functionName: 'transfer',
      args: [proxyAddress, amount],
    }),
    account: walletClient.account,
  });
 
  await publicClient.waitForTransactionReceipt({ hash });
}

Step 4: Create API Key

API keys are required for authenticated requests. They're created using L1 authentication (EIP-712 signature).

4.1 Build L1 Authentication Signature

async function buildL1Signature(
  walletClient: WalletClient,
  chainId: number,
  timestamp: number,
  nonce: number
): Promise<string> {
  const eoaAddress = walletClient.account.address;
  const MSG_TO_SIGN = "This message attests that I control the given wallet";
 
  const domain = {
    name: 'ClobAuthDomain',
    version: '1',
    chainId: chainId,
  };
 
  const types = {
    ClobAuth: [
      { name: 'address', type: 'address' },
      { name: 'timestamp', type: 'string' },
      { name: 'nonce', type: 'uint256' },
      { name: 'message', type: 'string' },
    ],
  };
 
  const message = {
    address: eoaAddress,
    timestamp: timestamp.toString(),
    nonce: nonce,
    message: MSG_TO_SIGN,
  };
 
  return await walletClient.signTypedData({
    domain,
    types,
    primaryType: 'ClobAuth',
    message,
  });
}

4.2 Get Nonce and Create API Key

import axios from 'axios';
 
async function createApiKey(walletClient: WalletClient): Promise<{
  key: string;
  secret: string;
  passphrase: string;
}> {
  const eoaAddress = walletClient.account.address;
  const timestamp = Math.floor(Date.now() / 1000);
  
  // Get nonce (optional, can use 0)
  const nonce = 0;
  
  // Build signature
  const signature = await buildL1Signature(
    walletClient,
    CHAIN_ID,
    timestamp,
    nonce
  );
 
  // Create API key
  const response = await axios.post(
    `https://api.probable.markets/public/api/v1/auth/api-key/${CHAIN_ID}`,
    {},
    {
      headers: {
        prob_address: eoaAddress,
        prob_signature: signature,
        prob_timestamp: timestamp.toString(),
        prob_nonce: nonce.toString(),
      },
    }
  );
 
  return {
    key: response.data.apiKey,
    secret: response.data.secret,
    passphrase: response.data.passphrase,
  };
}

Important: Store the secret and passphrase securely. They cannot be retrieved later.


Step 5: Create and Sign Order

Orders are created using EIP-712 typed data signing. The order structure includes maker (proxy wallet), signer (EOA), token ID, amounts, and other parameters.

5.1 Build Order Data

import { parseUnits } from 'viem';
 
interface UserOrder {
  tokenID: string;
  price: number;
  size: number;
  side: 0 | 1; // 0 = BUY, 1 = SELL
  feeRateBps?: number; // Max taker fee in basis points (175-1000)
  nonce?: number;
}
 
// Helper functions for rounding
function roundDown(num: number, decimals: number): number {
  if (decimalPlaces(num) <= decimals) return num;
  return Math.floor(num * 10 ** decimals) / 10 ** decimals;
}
 
function roundUp(num: number, decimals: number): number {
  if (decimalPlaces(num) <= decimals) return num;
  return Math.ceil(num * 10 ** decimals) / 10 ** decimals;
}
 
function roundNormal(num: number, decimals: number): number {
  if (decimalPlaces(num) <= decimals) return num;
  return Math.round((num + Number.EPSILON) * 10 ** decimals) / 10 ** decimals;
}
 
function decimalPlaces(num: number): number {
  if (Number.isInteger(num)) return 0;
  const arr = num.toString().split('.');
  if (arr.length <= 1) return 0;
  return arr[1]!.length;
}
 
// Rounding config based on tick size
const ROUNDING_CONFIG = {
  '0.1': { price: 1, size: 2, amount: 3 },
  '0.01': { price: 2, size: 2, amount: 4 },
  '0.001': { price: 3, size: 2, amount: 5 },
  '0.0001': { price: 4, size: 2, amount: 6 },
};
 
function calculateOrderAmounts(
  side: 0 | 1,
  size: number,
  price: number,
  tickSize: '0.01' | '0.1' | '0.001' | '0.0001'
): { rawMakerAmt: number; rawTakerAmt: number } {
  const roundConfig = ROUNDING_CONFIG[tickSize];
  const rawPrice = roundNormal(price, roundConfig.price);
 
  if (side === 0) { // BUY
    const rawTakerAmt = roundDown(size, roundConfig.size);
    let rawMakerAmt = rawTakerAmt * rawPrice;
    
    if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
      rawMakerAmt = roundUp(rawMakerAmt, roundConfig.amount + 4);
      if (decimalPlaces(rawMakerAmt) > roundConfig.amount) {
        rawMakerAmt = roundDown(rawMakerAmt, roundConfig.amount);
      }
    }
    
    return { rawMakerAmt, rawTakerAmt };
  } else { // SELL
    const rawMakerAmt = roundDown(size, roundConfig.size);
    let rawTakerAmt = rawMakerAmt * rawPrice;
    
    if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
      rawTakerAmt = roundUp(rawTakerAmt, roundConfig.amount + 4);
      if (decimalPlaces(rawTakerAmt) > roundConfig.amount) {
        rawTakerAmt = roundDown(rawTakerAmt, roundConfig.amount);
      }
    }
    
    return { rawMakerAmt, rawTakerAmt };
  }
}
 
function buildOrderData(
  userOrder: UserOrder,
  eoaAddress: string,
  proxyAddress: string,
  tickSize: '0.01' | '0.1' | '0.001' | '0.0001'
): {
  maker: string;
  signer: string;
  tokenId: string;
  makerAmount: string;
  takerAmount: string;
  side: number;
  feeRateBps: string;
  nonce: string;
  expiration: string;
  signatureType: number;
} {
  // Calculate raw amounts based on side
  const { rawMakerAmt, rawTakerAmt } = calculateOrderAmounts(
    userOrder.side,
    userOrder.size,
    userOrder.price,
    tickSize
  );
 
  const makerAmount = parseUnits(
    rawMakerAmt.toString(),
    18 // USDT decimals
  ).toString();
 
  const takerAmount = parseUnits(
    rawTakerAmt.toString(),
    18
  ).toString();
 
  const feeRateBps = userOrder.feeRateBps ?? 175;
  if (feeRateBps < 175 || feeRateBps > 1000) {
    throw new Error('feeRateBps must be between 175 and 1000 (inclusive)');
  }
 
  return {
    maker: proxyAddress, // Use proxy wallet as maker
    signer: eoaAddress,  // Use EOA as signer
    tokenId: userOrder.tokenID,
    makerAmount,
    takerAmount,
    side: userOrder.side,
    feeRateBps: feeRateBps.toString(),
    nonce: (userOrder.nonce || 0).toString(),
    expiration: '0', // 0 = no expiration
    signatureType: 1, // PROB_GNOSIS_SAFE
  };
}

5.2 Sign Order with EIP-712

async function signOrder(
  orderData: ReturnType<typeof buildOrderData>,
  walletClient: WalletClient,
  salt: string
): Promise<string> {
  const domain = {
    name: 'Probable CTF Exchange',
    version: '1',
    chainId: CHAIN_ID,
    verifyingContract: CTF_EXCHANGE_ADDRESS,
  };
 
  const types = {
    Order: [
      { name: 'salt', type: 'uint256' },
      { name: 'maker', type: 'address' },
      { name: 'signer', type: 'address' },
      { name: 'taker', type: 'address' },
      { name: 'tokenId', type: 'uint256' },
      { name: 'makerAmount', type: 'uint256' },
      { name: 'takerAmount', type: 'uint256' },
      { name: 'expiration', type: 'uint256' },
      { name: 'nonce', type: 'uint256' },
      { name: 'feeRateBps', type: 'uint256' },
      { name: 'side', type: 'uint8' },
      { name: 'signatureType', type: 'uint8' },
    ],
  };
 
  const taker = '0x0000000000000000000000000000000000000000';
  const message = {
    salt: BigInt(salt),
    maker: orderData.maker as `0x${string}`,
    signer: orderData.signer as `0x${string}`,
    taker: taker as `0x${string}`,
    tokenId: BigInt(orderData.tokenId),
    makerAmount: BigInt(orderData.makerAmount),
    takerAmount: BigInt(orderData.takerAmount),
    expiration: BigInt(orderData.expiration),
    nonce: BigInt(orderData.nonce),
    feeRateBps: BigInt(orderData.feeRateBps),
    side: orderData.side,
    signatureType: orderData.signatureType,
  };
 
  return await walletClient.signTypedData({
    domain,
    types,
    primaryType: 'Order',
    message,
  });
}

Step 6: Submit Order to API

Finally, submit the signed order to the API using L2 authentication (HMAC signature).

6.1 Build L2 HMAC Signature

import * as crypto from 'crypto';
 
function buildL2Signature(
  secret: string,
  timestamp: number,
  method: string,
  path: string,
  body: string
): string {
  const message = `${timestamp}${method}${path}${body}`;
  const base64Secret = Buffer.from(secret, 'base64');
  const hmac = crypto.createHmac('sha256', base64Secret);
  const sig = hmac.update(message).digest('base64');
  
  // URL-safe base64 encoding
  return sig.replace(/\+/g, '-').replace(/\//g, '_');
}

6.2 Submit Order

async function submitOrder(
  signedOrder: {
    salt: string;
    maker: string;
    signer: string;
    taker: string;
    tokenId: string;
    makerAmount: string;
    takerAmount: string;
    side: number;
    expiration: string;
    nonce: string;
    feeRateBps: string;
    signatureType: number;
    signature: string;
  },
  apiKey: string,
  secret: string,
  passphrase: string,
  eoaAddress: string
) {
  const requestBody = {
    deferExec: true,
    order: {
      salt: signedOrder.salt,
      maker: signedOrder.maker,
      signer: signedOrder.signer,
      taker: signedOrder.taker,
      tokenId: signedOrder.tokenId,
      makerAmount: signedOrder.makerAmount,
      takerAmount: signedOrder.takerAmount,
      side: signedOrder.side === 0 ? 'BUY' : 'SELL',
      expiration: signedOrder.expiration,
      nonce: signedOrder.nonce,
      feeRateBps: signedOrder.feeRateBps,
      signatureType: signedOrder.signatureType,
      signature: signedOrder.signature,
    },
    owner: signedOrder.maker, // Proxy wallet address
    orderType: 'GTC', // Good Till Cancel
  };
 
  const timestamp = Math.floor(Date.now() / 1000);
  const method = 'POST';
  const path = `/public/api/v1/order/${CHAIN_ID}`;
  const bodyString = JSON.stringify(requestBody);
 
  const signature = buildL2Signature(secret, timestamp, method, path, bodyString);
 
  const response = await axios.post(
    `https://api.probable.markets${path}`,
    requestBody,
    {
      headers: {
        'Content-Type': 'application/json',
        prob_address: eoaAddress, // Use EOA address for authentication
        prob_signature: signature,
        prob_timestamp: timestamp.toString(),
        prob_api_key: apiKey,
        prob_passphrase: passphrase,
      },
    }
  );
 
  return response.data;
}

Complete Example

Here's a complete example that ties everything together:

async function placeOrderFromEOA(
  walletClient: WalletClient,
  tokenId: string,
  price: number,
  size: number,
  side: 0 | 1
) {
  const eoaAddress = walletClient.account.address;
 
  // Step 1: Create or get proxy wallet
  const { exists, address: proxyAddress } = await checkProxyExists(eoaAddress);
  if (!exists) {
    await createProxyWallet(eoaAddress, walletClient);
  }
 
  // Step 2: Approve tokens
  const approvals = await checkApprovals(proxyAddress);
  if (approvals.needsUSDTForCTFToken || 
      approvals.needsUSDTForExchange || 
      approvals.needsCTFTokenForExchange) {
    await approveTokens(proxyAddress, privateKey);
  }
 
  // Step 3: Ensure USDT balance (user should deposit manually)
  // Check balance and prompt user if needed
 
  // Step 4: Create API key
  const apiKey = await createApiKey(walletClient);
 
  // Step 5: Create and sign order
  const userOrder: UserOrder = {
    tokenID: tokenId,
    price,
    size,
    side,
    feeRateBps: 175,
    nonce: 0,
  };
 
  const orderData = buildOrderData(userOrder, eoaAddress, proxyAddress, '0.01');
  const salt = `${Math.round(Math.random() * Date.now())}`;
  const signature = await signOrder(orderData, walletClient, salt);
 
  const signedOrder = {
    ...orderData,
    salt,
    taker: '0x0000000000000000000000000000000000000000',
    signature,
  };
 
  // Step 6: Submit order
  const result = await submitOrder(
    signedOrder,
    apiKey.key,
    apiKey.secret,
    apiKey.passphrase,
    eoaAddress
  );
 
  console.log('Order placed:', result);
  return result;
}

Key Points to Remember

  1. Address Usage:
    • Authentication headers (prob_address): Always use your EOA address
    • Order maker field: Use your proxy wallet address
    • Order signer field: Use your EOA address (signs the order)
    • Order owner field: Use your proxy wallet address
  2. Signature Types:
    • L1 authentication: EIP-712 signature with EOA
    • Order signing: EIP-712 signature with EOA (signatureType = 1 for PROB_GNOSIS_SAFE)
    • L2 API requests: HMAC-SHA256 signature with API secret
  3. Token Approvals:
    • All approvals are executed through the proxy wallet (Gnosis Safe)
    • Approvals can be batched in a single Safe transaction
  4. Order Parameters:
    • tickSize: Determines price precision ('0.01', '0.1', '0.001', or '0.0001')
    • deferExec: Set to true to defer execution
    • feeRateBps: Max taker fee in basis points (175-1000)
    • orderType: 'GTC' (Good Till Cancel) or 'IOC' (Immediate Or Cancel)

Next Steps