# O2 Exchange Trading Bot Integration Guide

O2 is a fully on-chain order book exchange built on the Fuel Network. Every order placement, fill, and cancellation happens as a blockchain transaction, while a gas-sponsoring executor and data-indexing layer abstract away complexity for traders. This guide gives you everything you need to go from zero to a working trading bot using HTTP requests, WebSocket connections, and secp256k1 signing.

All signing and encoding logic includes a working Python reference implementation using `coincurve` and `hashlib`. These implementations have been validated with unit tests and live testnet trading. For other languages, transpile the Python to your target language -- the byte layouts and algorithms are identical.

---

## 1. Quick Start (TL;DR)

1. **Generate a secp256k1 keypair** -- this is your owner wallet. Derive the B256 address (the 32-byte SHA-256 hash of the uncompressed public key).
2. **Fund your wallet** via the faucet (testnet/devnet/sandbox only — not available on mainnet): `POST https://fuel-o2-faucet.vercel.app/api/<network>/mint-v2` with `{"address":"0x<your_b256>"}` (replace `<network>` with `testnet`, `devnet`, or `sandbox`).
3. **Create a trading account**: `POST /v1/accounts` with `{"identity":{"Address":"0x<your_b256>"}}` -- save the returned `trade_account_id`.
4. **Deposit funds** into your trading account. On devnet/testnet/sandbox, mint directly to the contract via the faucet (the faucet is not available on mainnet). On mainnet, send an on-chain Fuel transfer to the `trade_account_id` contract (see Section 4.4, Option B).
5. **Fetch market info**: `GET /v1/markets` -- save the `market_id`, `contract_id`, asset IDs, and decimal configs.
6. **Whitelist your trading account**: `POST /analytics/v1/whitelist` with `{"tradeAccount":"0x<trade_account_id>"}` -- required before placing orders.
7. **Generate a second keypair** for the session wallet. Construct the `set_session` signing bytes. Sign with your owner wallet. Send `PUT /v1/session`.
8. **Fetch the current nonce**: `GET /v1/accounts?trade_account_id=0x<your_trade_account>` -- read the `nonce` field.
9. **Place an order**: construct a `POST /v1/session/actions` request with `CreateOrder` actions, sign the payload with your session wallet, and submit.
10. **Check order status**: `GET /v1/orders?market_id=0x...&contract=0x<trade_account_id>&direction=desc&count=20`.
11. **Manage positions**: cancel orders via `CancelOrder` action, check balances via `GET /v1/balance`, and withdraw via `POST /v1/accounts/withdraw`.

---

## 2. Environment Setup

### Devnet URLs

| Component | URL |
|-----------|-----|
| API Base URL | `https://api.devnet.o2.app` |
| WebSocket | `wss://api.devnet.o2.app/v1/ws` |
| Fuel RPC | `https://devnet.fuel.network/v1/graphql` |
| Faucet | `https://fuel-o2-faucet.vercel.app/api/devnet/mint-v2` |

### Testnet URLs

| Component | URL |
|-----------|-----|
| API Base URL | `https://api.testnet.o2.app` |
| WebSocket | `wss://api.testnet.o2.app/v1/ws` |
| Fuel RPC | `https://testnet.fuel.network/v1/graphql` |
| Faucet | `https://fuel-o2-faucet.vercel.app/api/testnet/mint-v2` |

All REST endpoints are prefixed with `/v1/`. All request and response bodies are `application/json`.

### Required Dependencies

Your bot needs these cryptographic capabilities:

- **secp256k1 signing** -- to produce 64-byte compact signatures (r + s, with recovery ID embedded in the high bit of s -- see "Fuel Signature Format" below)
- **SHA-256 hashing** -- for creating signing digests
- **UTF-8 encoding** -- for encoding function names into byte arrays
- **BigInteger / u64 encoding** -- for encoding 8-byte big-endian unsigned integers

Common libraries by language:

| Language | secp256k1 | SHA-256 | BigInt |
|----------|-----------|---------|--------|
| Python | `coincurve` or `ecdsa` | `hashlib` | Built-in `int` |
| Rust | `secp256k1` crate | `sha2` crate | `u64` native |
| JavaScript/TypeScript | `@noble/secp256k1` (v3: use `prehash: false`) | `@noble/hashes/sha2.js` (v2 requires `.js` suffix) | `BigInt` native |
| Go | `btcec` | `crypto/sha256` | `math/big` |

> **`@noble/secp256k1` v3 critical change:** v3 defaults to `prehash: true`, meaning `sign()` internally SHA-256 hashes the message. Since this guide's signing flow already pre-hashes (personalSign → `sha256(prefix + message)`, rawSign → `sha256(message)`), you **must** pass `prehash: false` or you will double-hash — producing a valid-looking signature that silently recovers to the wrong address (error 4000). v3 also requires manual hash configuration via `etc.hmacSha256Sync` and uses `sig.toCompactRawBytes()` + `sig.recovery` instead of the v2 `{ r, s, recovery }` object.

If you are using an **EVM wallet** as the owner wallet, you also need a **keccak256** library for address derivation and session signing:

| Language | Keccak-256 Library |
|----------|--------------------|
| Python | `pysha3` or `pycryptodome` (`Crypto.Hash.keccak`) |
| Rust | `sha3` crate |
| JavaScript/TypeScript | `@noble/hashes/sha3.js` (v2 requires `.js` suffix) or `js-sha3` |
| Go | `golang.org/x/crypto/sha3` |

---

## 3. Authentication Architecture

O2 uses a three-layer identity model that separates asset custody from trading operations. **Both Fuel-native and EVM wallets are supported as owner wallets.** Fuel wallets derive addresses via SHA-256 of the public key, while EVM wallets use keccak256 and are represented in B256 format with 12 zero-padded leading bytes. The API auto-detects the wallet type.

```
+-------------------+       signs session       +-------------------+
|                   |  ----------------------->  |                   |
|   Owner Wallet    |                            |  Trading Account  |
|   (secp256k1      |  <-- owns, can withdraw    |  (on-chain smart  |
|    keypair)       |                            |   contract)       |
+-------------------+                            +-------------------+
                                                        |
                                                        | delegates
                                                        v
                                                 +-------------------+
                                                 |                   |
                                                 |   Session Wallet  |
                                                 |   (ephemeral      |
                                                 |    secp256k1 key) |
                                                 +-------------------+
                                                        |
                                                        | can only
                                                        v
                                                 - Place orders
                                                 - Cancel orders
                                                 - Settle balances
                                                 - Cannot withdraw
                                                 - Cannot transfer
                                                 - Time-limited
```

### Why This Design?

- **Owner Wallet**: Your main key. It creates and controls the trading account. It signs session creation and can withdraw funds. Keep this extremely secure (ideally offline or in a hardware wallet).
- **Trading Account**: An on-chain Fuel smart contract (proxy) identified by `trade_account_id`. It holds your funds and executes trades on your behalf.
- **Session Wallet**: An ephemeral key generated specifically for your bot. It can only place/cancel orders on whitelisted market contracts. It cannot withdraw funds. It expires automatically (default 30 days). If compromised, the blast radius is limited to trading actions -- no funds can be stolen.

The owner wallet only needs to be online once -- to create the session. After that, the bot uses only the session wallet's private key.

---

## 4. Step-by-Step Walkthrough

### 4.1 Generate a Wallet

Generate a Fuel-compatible secp256k1 keypair. The address is a 32-byte (B256) value.

**What is B256?**
A B256 address is the raw 32-byte (256-bit) identifier derived from your public key. When displayed, it is a `0x`-prefixed 64-character hex string.

**Python reference implementation:**
```python
import hashlib
import os
from coincurve import PrivateKey

def generate_keypair():
    """Generate a secp256k1 keypair and derive the Fuel B256 address.
    Returns: (private_key_hex, public_key_bytes_65, b256_address_hex)
    """
    secret = os.urandom(32)
    pk = PrivateKey(secret)
    # Uncompressed public key: 65 bytes (0x04 prefix + 64 bytes)
    public_key = pk.public_key.format(compressed=False)
    # B256 address = SHA-256 of the 64-byte public key (skip 0x04 prefix)
    address = hashlib.sha256(public_key[1:]).digest()
    return secret.hex(), public_key, "0x" + address.hex()
```

#### EVM Wallet (Alternative)

For new bot implementations, a Fuel-native wallet (SHA-256 address derivation, as shown above) is recommended for simplicity. EVM wallet support is provided for users who already have EVM wallets they wish to use as owner wallets.

If you prefer to use an EVM-style wallet as the owner wallet, derive the address using keccak256 instead of SHA-256, then zero-pad it to B256 format:

**EVM address derivation (any language):**
```
private_key = random_bytes(32)
public_key  = secp256k1_derive_public_key(private_key)  // uncompressed 65 bytes
pubkey_64   = public_key[1:]                              // remove 0x04 prefix -> 64 bytes
evm_address = keccak256(pubkey_64)[-20:]                  // last 20 bytes of keccak256 hash
// evm_address is 20 bytes = 40 hex chars
// Example: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

// Convert to B256 for O2 API (zero-pad the first 12 bytes):
b256_address = "0x" + "000000000000000000000000" + hex(evm_address)
// Example: 0x0000000000000000000000005B38Da6a701c568545dCfcB03FcB875f56beddC4
```

The O2 API auto-detects EVM vs Fuel addresses by checking if the first 12 bytes of the B256 address are all zeros. No additional flags or parameters are needed.

**curl test** (verify your address works):
```bash
curl -s "https://api.devnet.o2.app/v1/accounts?owner=0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"
```

If you get `{"code":4002,"message":"Account not found"}`, your address format is correct -- you just haven't created a trading account yet.

### 4.2 Fund Your Wallet via Faucet (Testnet/Devnet/Sandbox Only)

**The faucet is only available on testnet, devnet, and sandbox networks. It is not available on mainnet (`api.o2.app`).** On mainnet, fund your trading account via on-chain transfer (see Section 4.4, Option B).

The faucet is available for `testnet`, `sandbox`, and `devnet` networks -- change the network segment in the URL path. Each call mints all available test tokens (fUSDC, fFUEL, fETH, fUSDT, fBTC, fMOOR).

**Faucet URL pattern:** `https://fuel-o2-faucet.vercel.app/api/<network>/mint-v2`

| Network | Faucet URL |
|---------|------------|
| Testnet | `https://fuel-o2-faucet.vercel.app/api/testnet/mint-v2` |
| Devnet | `https://fuel-o2-faucet.vercel.app/api/devnet/mint-v2` |
| Sandbox | `https://fuel-o2-faucet.vercel.app/api/sandbox/mint-v2` |

#### Mint to a wallet address

```bash
# Testnet example:
curl -X POST "https://fuel-o2-faucet.vercel.app/api/testnet/mint-v2" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"}'

# Devnet example:
curl -X POST "https://fuel-o2-faucet.vercel.app/api/devnet/mint-v2" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"}'
```

**Success response:**
```json
{
  "message": "Tokens minted successfully"
}
```

**Rate-limited response (60-second cooldown):**
```json
{
  "error": "You can request faucet funds only once every 60 seconds"
}
```

#### Mint directly to a trading account contract

You can also mint directly to a trading account contract (skips the need for an on-chain transfer):
```bash
# Testnet example:
curl -X POST "https://fuel-o2-faucet.vercel.app/api/testnet/mint-v2" \
  -H "Content-Type: application/json" \
  -d '{"contract": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"}'

# Devnet example:
curl -X POST "https://fuel-o2-faucet.vercel.app/api/devnet/mint-v2" \
  -H "Content-Type: application/json" \
  -d '{"contract": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"}'
```

**Important:** The faucet uses a single deployer wallet internally, so concurrent requests from different callers can cause UTXO conflicts. If you get an error, wait 60-90 seconds and retry.

**Note on mint amounts:** Each faucet call mints a small amount per token (e.g., ~500 fUSDC, ~0.5 fETH). This is barely enough for a single min_order trade. For a full test sequence (buy, sell, cancel, settle, market orders), you may need 2-3 mints. Account for the 60-second cooldown between mints in your setup flow.

### 4.3 Create a Trading Account

A trading account is an on-chain smart contract that holds your funds and executes trades. One owner wallet can have one trading account.

```bash
curl -X POST "https://api.devnet.o2.app/v1/accounts" \
  -H "Content-Type: application/json" \
  -d '{
    "identity": {
      "Address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"
    }
  }'
```

**Response:**
```json
{
  "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "nonce": "0x0"
}
```

**Request fields:**
| Field | Type | Description |
|-------|------|-------------|
| `identity.Address` | string | **Required.** Your owner wallet B256 address. |

**Response fields:**
| Field | Type | Description |
|-------|------|-------------|
| `trade_account_id` | string | The on-chain contract ID for your trading account. Save this -- you will use it everywhere. |
| `nonce` | string | The initial nonce of the trading account (starts at 0). Used for signing operations. |

**Note on nonce format:** The nonce is returned as a hex string (e.g., `"0x0"`) in the `POST /v1/accounts` response, but as a decimal string (e.g., `"0"`) in the `GET /v1/accounts` response. Always use the `GET /v1/accounts` nonce as the canonical source, as it returns a plain decimal string that can be parsed directly.

Behind the scenes, O2 deploys a proxy contract on Fuel and registers it in the Trading Account Registry.

### 4.4 Deposit Funds

Depositing funds means getting assets into the trading account contract so you can place orders.

#### Option A: Mint Directly to the Trading Account (Devnet/Testnet)

The simplest approach on devnet and testnet is to mint tokens directly to your trading account contract using the faucet:

```bash
curl -X POST "https://fuel-o2-faucet.vercel.app/api/devnet/mint-v2" \
  -H "Content-Type: application/json" \
  -d '{"contract": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"}'
```

This mints all test tokens (fUSDC, fFUEL, fETH, etc.) directly into your trading account. No on-chain transfer needed.

#### Option B: On-Chain Transfer (Mainnet / Production)

On mainnet, you must transfer assets from your wallet to the `trade_account_id` contract address. This is a standard Fuel blockchain transfer and requires constructing and submitting a Fuel transaction via the Fuel GraphQL API (`/v1/graphql`).

The Fuel transaction must be a `Transfer` output targeting the `trade_account_id` as a contract recipient, with the correct `asset_id` and `amount`. Consult the Fuel network documentation for constructing raw transactions via the GraphQL API.

**Important:** You need both the base asset AND the quote asset deposited to trade. For example, trading on FUEL/USDC requires both FUEL and USDC in your trading account.

### 4.5 Fetch Market Info

Get all available trading markets with their configuration:

```bash
curl -s "https://api.devnet.o2.app/v1/markets"
```

**Response:**
```json
{
  "books_registry_id": "0xabc123...",
  "accounts_registry_id": "0xdef456...",
  "trade_account_oracle_id": "0x789abc...",
  "chain_id": "0x0000000000000000",
  "base_asset_id": "0x...",
  "markets": [
    {
      "contract_id": "0x9ad52fb8a2be1c4603dfeeb8118a922c8cfafa8f260eeb41d68ade8d442be65b",
      "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96",
      "maker_fee": "0",
      "taker_fee": "100",
      "min_order": "1000000000",
      "dust": "1000",
      "price_window": 0,
      "base": {
        "symbol": "FUEL",
        "asset": "0xa1b2c3d4e5f6...",
        "decimals": 9,
        "max_precision": 3
      },
      "quote": {
        "symbol": "USDC",
        "asset": "0xf6e5d4c3b2a1...",
        "decimals": 9,
        "max_precision": 9
      }
    }
  ]
}
```

**Note:** The exact values for `maker_fee`, `taker_fee`, `min_order`, `dust`, `price_window`, and `max_precision` are per-market and may differ between devnet, testnet, and mainnet. Always fetch from the API and do not hardcode these values.

**Market selection:** Testnet and devnet may have multiple markets for similar pairs (e.g., both `fETH/fUSDC` and `ETH/USDC`). The faucet mints test tokens with the `f`-prefixed symbols (`fETH`, `fUSDC`, `fFUEL`, etc.). When choosing a market to trade, match the market's `base.asset` and `quote.asset` IDs against the tokens the faucet provides. Check your balance (via `GET /v1/balance`) for each asset ID to confirm which markets you have funded.

**Key fields to save for trading:**

| Field | Description | Why You Need It |
|-------|-------------|-----------------|
| `market_id` | Hex identifier for the trading pair | Used in all market-specific API calls |
| `contract_id` | On-chain order book contract ID | Used when creating sessions (whitelisting) |
| `chain_id` | Fuel network chain ID (top-level field, returned as hex string -- parse to integer). On testnet, this is `0` (returned as `"0x0000000000000000"`). This is a valid value -- do not treat zero as an error or missing field. | Required for session signing |
| `base.asset` | Asset ID of the base token | Used for balance queries and deposits |
| `quote.asset` | Asset ID of the quote token | Used for balance queries and deposits |
| `base.decimals` | Decimal places for base asset (typically 9) | Used to scale quantities |
| `quote.decimals` | Decimal places for quote asset (typically 9) | Used to scale prices |
| `base.max_precision` | Max meaningful decimal places for base | Used to truncate quantities (see Appendix) |
| `quote.max_precision` | Max meaningful decimal places for quote | Used to truncate prices (see Appendix) |
| `maker_fee` / `taker_fee` | Fee values as raw integer strings | Per-market fee configuration (see Fee section below) |
| `min_order` | Minimum forwarded amount (scaled integer) | See min_order note below. `"1000000000"` = 1.0 in 9-decimal asset |
| `dust` | Dust threshold (scaled integer) | Amounts below this are ignored |

**`min_order` is a quote-equivalent minimum for BOTH sides:**
- For all orders (buy AND sell): `(price * quantity) / 10^base_decimals` must be >= `min_order`
- For buy orders, this is also the forwarded (coins) amount
- For sell orders, the forwarded amount is `quantity`, but the quote-value check above is the binding constraint at low prices

**Example:** With `min_order = 1,000,000,000` (1 USDC in 9 decimals), a sell at price `200,000,000` (0.2 USDC) requires `quantity >= 5,000,000,000` base tokens, because `(200,000,000 * 5,000,000,000) / 10^9 = 1,000,000,000`.

Always verify `(price * quantity) / 10^base_decimals >= min_order` before submitting any order.

### 4.5b Whitelist Your Trading Account (Required Before Trading)

On testnet (and potentially other environments), the order book contract requires your trading account to be whitelisted before you can place orders. Without this step, `create_order` will fail on-chain with `TraderNotWhiteListed`.

This is a simple unauthenticated API call -- no signature is needed:

```bash
# Testnet:
curl -X POST "https://api.testnet.o2.app/analytics/v1/whitelist" \
  -H "Content-Type: application/json" \
  -d '{"tradeAccount": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"}'

# Devnet:
curl -X POST "https://api.devnet.o2.app/analytics/v1/whitelist" \
  -H "Content-Type: application/json" \
  -d '{"tradeAccount": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"}'
```

**Success response:**
```json
{
  "success": true,
  "tradeAccount": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
}
```

**Already whitelisted response:**
```json
{
  "success": true,
  "tradeAccount": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "alreadyWhitelisted": true
}
```

**Optional: Register an invite code (referral).** If you have a referral code, you can look up the referer address and register it via a `RegisterReferer` session action later:
```bash
# Look up referer address from invite code:
curl -s "https://api.testnet.o2.app/analytics/v1/referral/code-info?code=S9EVJK8K"
# Returns: { "valid": true, "ownerAddress": "0x...", "isActive": true }
```
The `RegisterReferer` action uses the same session action signing as `CreateOrder`/`CancelOrder`, with the referer's identity (Identity enum: 8-byte discriminant + 32-byte address) as the call_data.

### 4.6 Create a Trading Session

This is the **most critical step** for bot developers. A session delegates trading authority from your owner wallet to a session wallet. The session wallet can only trade on whitelisted order book contracts and cannot withdraw funds.

#### Step 1: Generate a Session Wallet

Generate a second secp256k1 keypair for the session:

```
session_private_key = random_bytes(32)
session_public_key  = secp256k1_derive_public_key(session_private_key)
session_address     = sha256(session_public_key[1:])  // B256 address
```

#### Step 2: Get the Current Nonce

```bash
curl -s "https://api.devnet.o2.app/v1/accounts?trade_account_id=0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
```

**Response:**
```json
{
  "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "trade_account": {
    "last_modification": 1734876543,
    "nonce": "0",
    "owner": {
      "Address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"
    },
    "synced_with_network": true
  }
}
```

**Actual testnet response (may differ from above):**
```json
{
  "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "trade_account": {
    "last_modification": 1734876543,
    "nonce": "0",
    "owner": {
      "Address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"
    },
    "sync_state": {
      "V3": { ... }
    }
  }
}
```
The `synced_with_network` boolean may appear as `sync_state` (an object) on some networks. Your code should handle both — the `nonce` field is consistently present in either shape.

**Response when account does not exist:**

If the owner address or trade_account_id has no associated account, the API returns HTTP 200 with null values — **not** a 404 or error code:
```json
{
  "session": null,
  "trade_account": null,
  "trade_account_id": null
}
```
Always check `trade_account_id` for `null` before proceeding. Do not rely on try/catch or HTTP status codes to detect a missing account.

Read the `nonce` field (starts at `"0"` for new accounts). **Note:** The nonce is returned as a JSON string (e.g., `"0"`, `"2"`), not a number. Parse it to an integer for use in signing byte construction and for incrementing after each action.

#### Step 3: Construct Signing Bytes

The owner wallet must sign a specific byte payload to authorize the session. The bytes represent a call to the smart contract's `set_session` function.

The `set_session` smart contract function takes an `Option<Session>` parameter. The Session struct layout is:
- `session_id`: Identity (8-byte discriminant + 32 bytes)
- `expiry`: Time { unix: u64 } (8 bytes)
- `contract_ids`: Vec\<ContractId\> (8-byte length + N * 32 bytes)

The `Option::Some` wrapper prepends a `u64(1)` discriminant. This MUST be included or signature verification will fail.

**Python reference implementation:**
```python
import struct

def u64_be(value: int) -> bytes:
    """Encode an integer as 8 bytes big-endian (u64)."""
    return struct.pack(">Q", value)

def build_session_signing_bytes(
    nonce: int,
    chain_id: int,
    session_address: bytes,   # 32-byte B256 address
    contract_ids: list,       # list of 32-byte contract IDs
    expiry: int,              # Unix timestamp in seconds
) -> bytes:
    """Build the signing bytes for set_session."""
    func_name = b"set_session"

    # Encode the session arguments
    encoded_args = bytearray()
    encoded_args += u64_be(1)                  # Option::Some
    encoded_args += u64_be(0)                  # Identity::Address
    encoded_args += session_address            # 32 bytes
    encoded_args += u64_be(expiry)             # expiry
    encoded_args += u64_be(len(contract_ids))  # number of contract IDs
    for cid in contract_ids:
        encoded_args += cid                    # 32 bytes each

    # Concatenate the signing payload
    signing_bytes = bytearray()
    signing_bytes += u64_be(nonce)
    signing_bytes += u64_be(chain_id)
    signing_bytes += u64_be(len(func_name))
    signing_bytes += func_name
    signing_bytes += encoded_args

    return bytes(signing_bytes)
```

**Key details:**
- `nonce` -- current nonce from the trading account (fetched in Step 2)
- `chain_id` -- the Fuel network chain ID (from `GET /v1/markets` response top-level `chain_id` field, or query Fuel RPC)
- `session_address` -- the B256 address of your session wallet
- `contract_ids` -- array of order book `contract_id` values the session is allowed to interact with
- `expiry` -- Unix timestamp in **seconds** when the session expires (must be at least 1 hour from now)

**Implementation note:** The session signing byte layout is derived from Fuel's ABI encoding specification. The exact byte sequence matters -- any deviation will produce error code 4000. A good approach is to build a small test: create a session, capture the exact bytes your implementation produces, and compare against a known-good signing implementation.

#### Step 4: Sign with Owner Wallet (personalSign)

The owner signs using Fuel's `personalSign` format, which prepends a message prefix before hashing and signing.

**Fuel Signature Format (CRITICAL):**
Fuel uses a compact 64-byte signature format where the **recovery ID is embedded in the most significant bit (MSB) of byte 32** (the first byte of the `s` component). This is NOT the same as a plain `(r, s)` concatenation. The server reads the MSB of byte 32 to determine the recovery ID. If this bit is incorrect, signature verification will fail with error 4000 ("invalid fuel address provided").

**Python reference implementation:**
```python
import hashlib
from coincurve import PrivateKey

def fuel_compact_sign(private_key_bytes: bytes, digest: bytes) -> bytes:
    """Sign a 32-byte digest and return 64-byte Fuel compact signature.
    The recovery ID is embedded in the MSB of byte 32 (first byte of s).
    """
    pk = PrivateKey(private_key_bytes)
    # sign_recoverable returns 65 bytes: [r(32)] [s(32)] [recovery_id(1)]
    # NOTE: recovery_id is at byte 64 (last byte), NOT byte 0
    sig = pk.sign_recoverable(digest, hasher=None)
    r = sig[0:32]
    s = bytearray(sig[32:64])
    recovery_id = sig[64]

    # Embed recovery ID in the MSB of s[0]
    s[0] = (recovery_id << 7) | (s[0] & 0x7F)

    return r + bytes(s)

def personal_sign(private_key_bytes: bytes, message_bytes: bytes) -> bytes:
    """Sign using Fuel's personalSign format (for session creation).
    prefix = b"\\x19Fuel Signed Message:\\n" + str(len(message)) + message
    digest = sha256(prefix)
    """
    prefix = b"\x19Fuel Signed Message:\n"
    length_str = str(len(message_bytes)).encode("utf-8")
    full_message = prefix + length_str + message_bytes
    digest = hashlib.sha256(full_message).digest()
    return fuel_compact_sign(private_key_bytes, digest)

def raw_sign(private_key_bytes: bytes, message_bytes: bytes) -> bytes:
    """Sign using raw SHA-256 hash, no prefix (for session actions).
    digest = sha256(message_bytes)
    """
    digest = hashlib.sha256(message_bytes).digest()
    return fuel_compact_sign(private_key_bytes, digest)
```

**Signature construction steps (important for non-Python implementations):**

The Python `fuel_compact_sign` above relies on `coincurve` which handles some steps internally. If implementing in another language, the full procedure is:

1. **Sign the 32-byte digest** with secp256k1 to get `(r, s, recovery_id)`.
2. **Ensure low-s normalization:** if `s > secp256k1_order / 2`, negate `s` (i.e., `s = order - s`) and flip `recovery_id` (i.e., `recovery_id ^= 1`). Most secp256k1 libraries handle this automatically, but verify yours does — Fuel rejects high-s signatures.
3. **Encode recovery ID into byte 32:** set `signature[32] = (recovery_id << 7) | (s[0] & 0x7F)`. This embeds the recovery ID in the MSB of the first byte of `s`, producing a 64-byte compact signature.

Step 2 is handled internally by `coincurve.sign_recoverable()` in Python. If your library does NOT normalize low-s automatically, you must do it yourself or signatures will be rejected.

**Notes for other languages:**
- **Rust (`secp256k1`):** `RecoverableSignature` provides `recovery_id()` and `serialize_compact()`. The library normalizes low-s. Apply the bit encoding to `compact[32]`.
- **JavaScript (`@noble/secp256k1` v3):** The v3 API differs significantly from v2. Example signing flow:
  ```js
  import { sign } from '@noble/secp256k1';
  // CRITICAL: prehash must be false — the digest is already SHA-256 hashed
  const sig = sign(digest, privateKey, { prehash: false });
  const r = sig.toCompactRawBytes().slice(0, 32);
  const s = sig.toCompactRawBytes().slice(32, 64);
  const recovery = sig.recovery; // 0 or 1
  // Embed recovery ID in MSB of s[0]
  const compact = new Uint8Array(64);
  compact.set(r, 0);
  compact.set(s, 32);
  compact[32] = (recovery << 7) | (compact[32] & 0x7F);
  ```
  Low-s is already normalized by the library. If using v2 (deprecated), `sign()` returns `{ r, s, recovery }` directly.

This format applies to **all Fuel-style signatures**: session creation (`personalSign`) and session action signing (raw SHA-256). It does NOT apply to EVM-style signatures (Step 4b), which use their own compact encoding.

#### Step 4b: EVM Owner Signing (Alternative)

If your owner wallet is an EVM wallet (zero-padded B256 address), the signing process uses **Ethereum's `personal_sign` prefix and keccak256** instead of Fuel's prefix and SHA-256:

```
message_bytes = build_session_signing_bytes(nonce, chain_id, ...)

// 1. Apply Ethereum personal_sign prefix
prefix = "\x19Ethereum Signed Message:\n" + str(length(message_bytes))
prefixed_message = concat([utf8_bytes(prefix), message_bytes])

// 2. Hash with keccak256 (NOT SHA-256)
digest = keccak256(prefixed_message)

// 3. Sign with secp256k1
signature = secp256k1_sign(owner_private_key, digest)
// Convert to compact 64-byte format: r (32 bytes) + yParityAndS (32 bytes)
// where yParityAndS encodes the y-parity bit in the MSB of the first byte of s
```

Send the signature as: `{"Secp256k1": "0x<128_hex_chars>"}`

The raw message bytes (from `build_session_signing_bytes`) are constructed identically to the Fuel path -- only the prefix and hash algorithm differ.

**Important -- Session Wallet Signing is Always Fuel-Style:**
Even when the owner wallet is EVM-style, the **session wallet** always uses Fuel-style signing for trading actions (raw SHA-256 hash, no prefix). The session verification code does NOT check for EVM address padding. Specifically:
- Session wallet address = `sha256(uncompressed_pubkey_64_bytes)` -- full 32 bytes, NO zero padding
- Session action signing = `sha256(message_bytes)` then `secp256k1_sign` -- NO `personal_sign` prefix, NO keccak256
- This is exactly the same as documented in Step 3 of Section 4.7 (Signing Process for Session Actions)

#### Step 5: Send the Session Request

```bash
curl -X PUT "https://api.devnet.o2.app/v1/session" \
  -H "Content-Type: application/json" \
  -H "O2-Owner-Id: 0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5" \
  -d '{
    "contract_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
    "session_id": {
      "Address": "0xaabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
    },
    "signature": {
      "Secp256k1": "0xe5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f80910a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304a5b6c7d8e9f0"
    },
    "contract_ids": [
      "0x9ad52fb8a2be1c4603dfeeb8118a922c8cfafa8f260eeb41d68ade8d442be65b"
    ],
    "nonce": "0",
    "expiry": "1737504000"
  }'
```

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Header: `O2-Owner-Id` | string | Yes | **HTTP header** (not body field). Your owner wallet's B256 address. Required for all session endpoints. Missing it returns a generic 400 error. |
| `contract_id` | string | Yes | Your trading account contract ID (`trade_account_id`) |
| `session_id` | object | Yes | Identity of the session wallet: `{"Address":"0x<session_b256>"}` |
| `signature` | object | Yes | Owner's signature: `{"Secp256k1":"0x<128_hex_chars>"}` |
| `contract_ids` | string[] | Yes | Array of order book contract IDs the session can interact with |
| `nonce` | string | Yes | Current nonce of the trading account (as string) |
| `expiry` | string | Yes | Unix timestamp in seconds when the session expires |

**Response:**
```json
{
  "tx_id": "0xabcdef1234567890...",
  "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "contract_ids": ["0x9ad52fb8a2be1c4603dfeeb8118a922c8cfafa8f260eeb41d68ade8d442be65b"],
  "session_id": {
    "Address": "0xaabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
  },
  "session_expiry": "1737504000"
}
```

**Supported signature types for sessions:** `Secp256k1` is the primary supported signature type. `TypedSecp256k1` (SRC-16/EIP-712 domain separation) is also defined but **not yet available on testnet** -- use `Secp256k1` for all environments. The API returns error code 4000 for `Secp256r1` or `Ed25519`.

### 4.7 Place an Order

Orders are placed via `POST /v1/session/actions`. This is the primary trading endpoint. Actions are grouped by market, and up to 5 actions across up to 5 markets can be submitted per request.

#### Signing Process for Session Actions

Session action signing is different from session creation signing. Session actions use a **raw SHA-256 hash** (NOT `personalSign`).

**Step 1: Convert high-level actions to low-level contract calls.**

Each action in your `actions` array maps to a contract call with specific parameters:

| Action | Target Contract | Function Selector | Amount (coins forwarded) | Asset ID |
|--------|----------------|-------------------|--------------------------|----------|
| `CreateOrder` (Buy) | order book `contract_id` | `OrderBook.create_order` selector | `(price * quantity) / 10^base_decimals` | quote asset ID |
| `CreateOrder` (Sell) | order book `contract_id` | `OrderBook.create_order` selector | `quantity` | base asset ID |
| `CancelOrder` | order book `contract_id` | `OrderBook.cancel_order` selector | `0` | zeroed (0x00...00) |
| `SettleBalance` | order book `contract_id` | `OrderBook.settle_balance` selector | `0` | zeroed (0x00...00) |
| `RegisterReferer` | `accounts_registry_id` (top-level field from `GET /v1/markets`) | `TradeAccountRegistry.register_referer` selector | `0` | zeroed (0x00...00) |

**Function selectors** in Fuel ABI are **not** hash-based. They are the raw function name encoded as: `u64_be(name_length) + utf8_bytes(name)`. This is a variable-length encoding.

Precomputed selectors for each function:
```
create_order    -> 00 00 00 00 00 00 00 0C  63 72 65 61 74 65 5F 6F 72 64 65 72  (20 bytes)
cancel_order    -> 00 00 00 00 00 00 00 0C  63 61 6E 63 65 6C 5F 6F 72 64 65 72  (20 bytes)
settle_balance  -> 00 00 00 00 00 00 00 0E  73 65 74 74 6C 65 5F 62 61 6C 61 6E 63 65  (22 bytes)
register_referer -> 00 00 00 00 00 00 00 10  72 65 67 69 73 74 65 72 5F 72 65 66 65 72 65 72  (24 bytes)
```

The `gas` value for all calls should be set to `u64::MAX = 18446744073709551615` (the API overrides this with its own configured value).

**Call data** is the ABI-encoded arguments for each function:
- `CreateOrder`: ABI-encoded `OrderArgs` struct (see encoding detail below)
- `CancelOrder`: ABI-encoded 32-byte order ID
- `SettleBalance`: ABI-encoded Identity (the `to` destination: 8-byte discriminant + 32-byte address)
- `RegisterReferer`: ABI-encoded Identity (the referer: 8-byte discriminant + 32-byte address)

**CreateOrder call_data encoding (`OrderArgs` struct):**

The current Fuel ABI encoder (v1, fuels SDK 0.76+) uses **tightly packed** enum encoding -- each variant is `u64(discriminant)` followed only by its actual payload, with **no padding** to the largest variant size.

```
OrderArgs layout:
  price:      u64 (8 bytes big-endian)
  quantity:   u64 (8 bytes big-endian)
  order_type: enum (u64 variant index + tightly packed payload, NO padding)

OrderType variant indexes and payloads (matches Sway enum declaration order):
  Limit        = variant 0:  u64(0) + u64(price) + u64(timestamp)   [24 bytes]
  Spot         = variant 1:  u64(1)                                  [8 bytes]
  FillOrKill   = variant 2:  u64(2)                                  [8 bytes]
  PostOnly     = variant 3:  u64(3)                                  [8 bytes]
  Market       = variant 4:  u64(4)                                  [8 bytes]
  BoundedMarket = variant 5: u64(5) + u64(max_price) + u64(min_price) [24 bytes]

Total OrderArgs size varies by order type:
  Spot/Market/FillOrKill/PostOnly: 8 + 8 + 8 = 24 bytes
  Limit/BoundedMarket:             8 + 8 + 24 = 40 bytes
```

**Note:** The `side` (Buy/Sell) is NOT part of the `OrderArgs` struct for call_data encoding -- it determines which asset and amount are forwarded to the contract call (see the action-to-call mapping table above).

**Python reference implementation — action-to-call conversion and OrderArgs encoding:**
```python
import struct

GAS_MAX = 18446744073709551615  # u64::MAX

def u64_be(value: int) -> bytes:
    """Encode an integer as 8 bytes big-endian (u64)."""
    return struct.pack(">Q", value)

def function_selector(name: str) -> bytes:
    """Encode a Fuel ABI function selector: u64_be(len(name)) + utf8(name)."""
    name_bytes = name.encode("utf-8")
    return u64_be(len(name_bytes)) + name_bytes

def encode_identity(discriminant: int, address_bytes: bytes) -> bytes:
    """Encode a Fuel Identity enum: u64(discriminant) + 32-byte address.
    discriminant: 0 = Address, 1 = ContractId
    """
    return u64_be(discriminant) + address_bytes

def encode_order_args(price: int, quantity: int, order_type: str, order_type_data: dict = None) -> bytes:
    """Encode OrderArgs struct for CreateOrder call_data.
    OrderArgs = u64(price) + u64(quantity) + order_type_encoding
    """
    result = u64_be(price) + u64_be(quantity)

    if order_type == "Limit":
        limit_price = int(order_type_data["price"])
        timestamp = int(order_type_data["timestamp"])
        result += u64_be(0) + u64_be(limit_price) + u64_be(timestamp)
    elif order_type == "Spot":
        result += u64_be(1)
    elif order_type == "FillOrKill":
        result += u64_be(2)
    elif order_type == "PostOnly":
        result += u64_be(3)
    elif order_type == "Market":
        result += u64_be(4)
    elif order_type == "BoundedMarket":
        max_price = int(order_type_data["max_price"])
        min_price = int(order_type_data["min_price"])
        result += u64_be(5) + u64_be(max_price) + u64_be(min_price)

    return result

def action_to_call(action: dict, market_info: dict) -> dict:
    """Convert a high-level action to a low-level contract call.
    Returns dict with: contract_id, function_selector, amount, asset_id, gas, call_data
    """
    contract_id = bytes.fromhex(market_info["contract_id"][2:])
    zero_asset = bytes(32)

    if "CreateOrder" in action:
        data = action["CreateOrder"]
        price = int(data["price"])
        quantity = int(data["quantity"])
        side = data["side"]
        base_decimals = market_info["base"]["decimals"]

        if side == "Buy":
            amount = (price * quantity) // (10 ** base_decimals)
            asset_id = bytes.fromhex(market_info["quote"]["asset"][2:])
        else:  # Sell
            amount = quantity
            asset_id = bytes.fromhex(market_info["base"]["asset"][2:])

        # Parse order_type from JSON format
        ot = data["order_type"]
        if isinstance(ot, str):
            ot_name, ot_data = ot, None
        elif isinstance(ot, dict):
            if "Limit" in ot:
                ot_name = "Limit"
                ot_data = {"price": ot["Limit"][0], "timestamp": ot["Limit"][1]}
            elif "BoundedMarket" in ot:
                ot_name, ot_data = "BoundedMarket", ot["BoundedMarket"]

        call_data = encode_order_args(price, quantity, ot_name, ot_data)
        return {"contract_id": contract_id, "function_selector": function_selector("create_order"),
                "amount": amount, "asset_id": asset_id, "gas": GAS_MAX, "call_data": call_data}

    elif "CancelOrder" in action:
        order_id = bytes.fromhex(action["CancelOrder"]["order_id"][2:])
        return {"contract_id": contract_id, "function_selector": function_selector("cancel_order"),
                "amount": 0, "asset_id": zero_asset, "gas": GAS_MAX, "call_data": order_id}

    elif "SettleBalance" in action:
        to = action["SettleBalance"]["to"]
        disc = 1 if "ContractId" in to else 0
        addr = bytes.fromhex(list(to.values())[0][2:])
        return {"contract_id": contract_id, "function_selector": function_selector("settle_balance"),
                "amount": 0, "asset_id": zero_asset, "gas": GAS_MAX,
                "call_data": encode_identity(disc, addr)}
```

**Step 2: Construct the signing bytes and sign with the session wallet.**

CRITICAL: Session actions use `raw_sign` (raw SHA-256 hash, NO prefix). Using `personalSign` here will fail with error 4000.

```python
def encode_option_call_data(data_or_none) -> bytes:
    """Encode Option for call_data: None -> u64(0), Some -> u64(1) + u64(len) + data."""
    if data_or_none is None:
        return u64_be(0)
    return u64_be(1) + u64_be(len(data_or_none)) + data_or_none

def build_actions_signing_bytes(nonce: int, calls: list) -> bytes:
    """Build the signing bytes from a list of low-level calls.
    Layout: u64(nonce) + u64(num_calls) + for each call: contract_id(32) +
            u64(selector_len) + selector + u64(amount) + asset_id(32) +
            u64(gas) + encode_option(call_data)
    """
    result = bytearray()
    result += u64_be(nonce)
    result += u64_be(len(calls))

    for call in calls:
        selector = call["function_selector"]
        result += call["contract_id"]                          # 32 bytes
        result += u64_be(len(selector))                        # 8 bytes
        result += selector                                     # variable
        result += u64_be(call["amount"])                       # 8 bytes
        result += call["asset_id"]                             # 32 bytes
        result += u64_be(call["gas"])                          # 8 bytes
        result += encode_option_call_data(call["call_data"])   # 8+ bytes

    return bytes(result)

def sign_actions(session_private_key: bytes, nonce: int, calls: list) -> str:
    """Sign the actions payload with the session wallet using raw_sign."""
    signing_bytes = build_actions_signing_bytes(nonce, calls)
    signature = raw_sign(session_private_key, signing_bytes)  # raw SHA-256, NO prefix
    return "0x" + signature.hex()
```

**Implementation note:** The signing byte construction must exactly match what the server computes when it converts your high-level actions (CreateOrder, CancelOrder, etc.) into low-level `CallContractArg` structs. The server performs this conversion, then verifies your signature against the resulting bytes.

#### Complete Request Example

```bash
curl -X POST "https://api.devnet.o2.app/v1/session/actions" \
  -H "Content-Type: application/json" \
  -H "O2-Owner-Id: 0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5" \
  -d '{
    "actions": [
      {
        "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96",
        "actions": [
          {
            "CreateOrder": {
              "side": "Buy",
              "price": "100000000",
              "quantity": "5000000000",
              "order_type": "Spot"
            }
          }
        ]
      }
    ],
    "signature": {
      "Secp256k1": "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f80910a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304a5b6"
    },
    "nonce": "1",
    "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
    "session_id": {
      "Address": "0xaabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
    },
    "collect_orders": true
  }'
```

**Request body fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| Header: `O2-Owner-Id` | string | Yes | **HTTP header** (not body field). Your owner wallet's B256 address. Required for all session/action endpoints. |
| `actions` | array | Yes | Array of market-grouped actions (see below) |
| `signature` | object | Yes | Session wallet signature: `{"Secp256k1":"0x<128_hex>"}` |
| `nonce` | string | Yes | Current nonce of the trading account |
| `trade_account_id` | string | Yes | Your trading account contract ID |
| `session_id` | object | Yes | Session wallet identity: `{"Address":"0x<session_b256>"}` |
| `variable_outputs` | number | No | Number of variable outputs (max 10, default varies) |
| `min_gas_limit` | string | No | Minimum gas limit for the transaction |
| `estimate_gas_usage` | boolean | No | If true, estimate gas before submitting |
| `collect_orders` | boolean | No | If true, wait for events and return created orders |

**Actions array structure:**
```json
[
  {
    "market_id": "0x<hex_market_id>",
    "actions": [
      { "CreateOrder": { ... } },
      { "CancelOrder": { ... } },
      { "SettleBalance": { ... } }
    ]
  }
]
```

**Constraints:**
- Maximum **5 actions** total across all markets
- Maximum **5 markets** per request
- Maximum **10 variable outputs** per request

**Response (with `collect_orders: true`):**
```json
{
  "tx_id": "0xfedcba0987654321...",
  "orders": [
    {
      "order_id": "0x1122334455667788...",
      "price": "100000000",
      "quantity": "5000000000",
      "desired_quantity": "5000000000",
      "side": "Buy",
      "timestamp": 1734876543,
      "base_decimals": 9,
      "account": {
        "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
      },
      "fill": null,
      "order_tx_history": []
    }
  ]
}
```

> **Note:** The actual `collect_orders: true` response may include additional fields beyond what is shown here, such as `base_decimals`, `desired_quantity`, `fill` (non-null when an order is partially or fully filled), and `order_tx_history`. The `quantity` field may reflect the remaining (unfilled) quantity rather than the original. Treat the response shape as a superset of this example and handle unknown fields gracefully.

**Important -- Nonce Management:**
After any `session/actions` call, increment the nonce locally by 1. **The nonce increments on-chain even if the transaction reverts** (e.g., `TraderNotWhiteListed`, `NotEnoughBalance`). On any unexpected error, re-fetch the nonce from the API to resync: `GET /v1/accounts?trade_account_id=0x...` and read the `nonce` field.

**Important -- Settle Balance:**
After trades fill, funds sit as "unlocked" in the order book contract. Even though `trading_account_balance` appears non-zero in the balance API, the on-chain state may require settlement first. Always include a `SettleBalance` action before `CreateOrder` actions in the same batch to ensure funds are available on-chain:

```json
{
  "market_id": "0x09c17f...",
  "actions": [
    {
      "SettleBalance": {
        "to": {
          "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
        }
      }
    },
    {
      "CreateOrder": {
        "side": "Buy",
        "price": "100000000",
        "quantity": "5000000000",
        "order_type": "Spot"
      }
    }
  ]
}
```

**On-chain validation rules (contract will revert if violated):**
- **PricePrecision:** Prices MUST be multiples of `10^(decimals - max_precision)`. Use the truncation formula: `price = floor(price / truncate_factor) * truncate_factor`. Non-truncated prices cause the contract to revert with `PricePrecision`.
- **FractionalPrice:** `(price * quantity) % 10^base_decimals` must equal 0. Choose quantities such that the quote value `(price * quantity)` is evenly divisible by `10^base_decimals`. If not, the contract reverts with `FractionalPrice`.

These constraints apply to all `CreateOrder` actions. Violating them causes a silent on-chain revert -- the transaction will fail without a clear error message unless you inspect the receipt `reason` field.

### 4.8 Check Order Status

#### Get All Orders

```bash
curl -s "https://api.devnet.o2.app/v1/orders?market_id=0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96&contract=0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f&direction=desc&count=20"
```

**Query parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `market_id` | string | Yes | Hex market ID |
| `account` | string | Conditional | Owner wallet address (use this OR `contract`) |
| `contract` | string | Conditional | Trading account contract ID (use this OR `account`) |
| `direction` | string | Yes | `"asc"` or `"desc"` |
| `count` | number | Yes | Number of orders (max 200) |
| `is_open` | boolean | No | Filter for open orders only |
| `start_timestamp` | number | No | Pagination start timestamp (ms) |
| `start_order_id` | string | No | Pagination start order ID |

**Response:**
```json
{
  "identity": {
    "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
  },
  "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96",
  "orders": [
    {
      "order_id": "0x1122334455667788...",
      "side": "Buy",
      "order_type": "Spot",
      "quantity": "5000000000",
      "quantity_fill": "0",
      "price": "100000000",
      "price_fill": "0",
      "timestamp": "1734876543",
      "close": false,
      "partially_filled": false,
      "cancel": false
    }
  ]
}
```

**Note:** The orders response wraps orders in a nested structure with `identity`, `market_id`, and `orders` fields. The `orders` array contains the actual order objects.

**Pagination:** Use both `start_timestamp` and `start_order_id` together (both or neither).

#### Get a Single Order

```bash
curl -s "https://api.devnet.o2.app/v1/order?market_id=0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96&order_id=0x1122334455667788aabbccdd11223344aabbccdd11223344aabbccdd11223344"
```

**Query parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `market_id` | string | Yes | Hex market ID |
| `order_id` | string | Yes | Specific order ID |

### 4.9 Cancel an Order

Cancellations are submitted as session actions, exactly like order placement:

```bash
curl -X POST "https://api.devnet.o2.app/v1/session/actions" \
  -H "Content-Type: application/json" \
  -H "O2-Owner-Id: 0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5" \
  -d '{
    "actions": [
      {
        "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96",
        "actions": [
          {
            "CancelOrder": {
              "order_id": "0x1122334455667788aabbccdd11223344aabbccdd11223344aabbccdd11223344"
            }
          }
        ]
      }
    ],
    "signature": {
      "Secp256k1": "0x<session_signature_128_hex>"
    },
    "nonce": "2",
    "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
    "session_id": {
      "Address": "0xaabbccdd11223344aabbccdd11223344aabbccdd11223344aabbccdd11223344"
    }
  }'
```

You can batch cancel multiple orders in a single request (up to 5 actions total). You can also mix cancellations with new orders in the same request.

### 4.10 Check Balances

```bash
curl -s "https://api.devnet.o2.app/v1/balance?asset_id=0xf6e5d4c3b2a1...&contract=0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
```

**Query parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `asset_id` | string | Yes | Asset ID to check (from `GET /v1/markets` -> `base.asset` or `quote.asset`) |
| `address` | string | Conditional | Owner wallet address (use this OR `contract`) |
| `contract` | string | Conditional | Trading account contract ID (use this OR `address`) |

**Response:**
```json
{
  "order_books": {
    "0x9ad52fb8a2be1c4603dfeeb8118a922c8cfafa8f260eeb41d68ade8d442be65b": {
      "locked": "2000000000",
      "unlocked": "34878720000"
    }
  },
  "total_locked": "2000000000",
  "total_unlocked": "34878720000",
  "trading_account_balance": "25000000000"
}
```

**Balance model explained:**

| Field | Description |
|-------|-------------|
| `trading_account_balance` | Funds sitting in the trading account contract, available for new orders after settle |
| `total_unlocked` | Funds in order books from filled trades, liquid and can be settled back to the trading account |
| `total_locked` | Funds locked in open orders, cannot be withdrawn until orders are cancelled |
| `order_books.<book_id>.locked` | Funds locked in active orders on this specific order book |
| `order_books.<book_id>.unlocked` | Funds from fills on this specific order book, available to settle |

**Available balance for trading** = `trading_account_balance` (after settling unlocked funds)

To make `total_unlocked` funds available for new orders, include a `SettleBalance` action in your next `session/actions` request.

### 4.11 Withdraw Funds

`POST /v1/accounts/withdraw` moves funds from your trading account to a Fuel Ignition destination. This requires an **owner signature** (not a session signature).

**Important:** This endpoint is **not** a Fast Bridge withdrawal to Base, Ethereum, or another EVM destination chain. For EVM-chain withdrawals, use the Fast Bridge withdrawal flow instead. That path calls `AssetRegistry.withdraw_via_fast_bridge_with_fee` through an owner-signed account action such as `WithdrawViaFastBridgeWithFee`, and requires a destination chain, EVM recipient, and gas-oracle fee quote.

Implementation references:

- [`fast-bridge/withdrawals` skill](https://github.com/o2-exchange/skills/blob/main/skills/fast-bridge/withdrawals/SKILL.md)
- [Fast Bridge withdrawal reference flows](https://github.com/o2-exchange/skills/tree/main/skills/fast-bridge/withdrawals/references)
- [Fuel Fast Bridge withdrawal flow](/fast-bridge#withdrawal-flow-fuel--evm)

```bash
curl -X POST "https://api.devnet.o2.app/v1/accounts/withdraw" \
  -H "Content-Type: application/json" \
  -H "O2-Owner-Id: 0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5" \
  -d '{
    "trade_account_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
    "signature": {
      "Secp256k1": "0xe5f6a7b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f80910a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304a5b6c7d8e9f0"
    },
    "nonce": "3",
    "to": {
      "Address": "0x91F0E90eff49ee08c0d42E617F5ec4e7A52F5b3A9D9E3Bbd0Aa995567fe98df5"
    },
    "asset_id": "0xf6e5d4c3b2a1...",
    "amount": "10000000000"
  }'
```

**Request fields:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `trade_account_id` | string | Yes | Your trading account contract ID |
| `signature` | object | Yes | Owner wallet signature: `{"Secp256k1":"0x<128_hex_chars>"}` |
| `nonce` | string | Yes | Current trading account nonce |
| `to` | object | Yes | Fuel Ignition destination identity: `{"Address":"0x<b256>"}` |
| `asset_id` | string | Yes | Asset ID to withdraw |
| `amount` | string | Yes | Amount to withdraw (scaled to decimals) |

**Important:** Withdrawals require the owner key. Session keys cannot withdraw funds -- this is a core security feature. Only `Secp256k1` signatures are supported for withdrawals (`TypedSecp256k1` is defined but not yet available on testnet).

---

## 5. WebSocket Real-Time Data

### Connection

Connect to the WebSocket endpoint for real-time streaming data:

```
wss://api.devnet.o2.app/v1/ws
```

All messages are JSON. Subscriptions use an `action` field as a discriminator.

**Subscription limits:** Maximum 10 topics (identities/accounts) per subscription request. Exceeding this limit returns error code 6001.

### Subscribe to Order Book Depth

```json
{
  "action": "subscribe_depth",
  "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96",
  "precision": "10"
}
```

**Initial snapshot response:**
```json
{
  "action": "subscribe_depth",
  "view": {
    "buys": [{"price": "100000000", "quantity": "5000000000"}, {"price": "99000000", "quantity": "3000000000"}],
    "sells": [{"price": "101000000", "quantity": "2000000000"}, {"price": "102000000", "quantity": "4000000000"}]
  },
  "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96"
}
```

**Update response (subsequent):**
```json
{
  "action": "subscribe_depth_update",
  "changes": {
    "buys": [{"price": "100000000", "quantity": "6000000000"}],
    "sells": [{"price": "101000000", "quantity": "0"}]
  },
  "market_id": "0x09c17f...",
  "onchain_timestamp": "1734876543",
  "seen_timestamp": "1734876544"
}
```

**Notes:**
- `buys` are sorted by price descending (best bid first)
- `sells` are sorted by price ascending (best ask first)
- Each entry is an object with `"price"` and `"quantity"` fields (string values)
- A quantity of `"0"` means the price level has been removed
- `precision` controls price aggregation: valid values are `"10"`, `"100"`, `"1000"`, `"10000"`, `"100000"`, `"1000000"`, `"10000000"`, `"100000000"`, `"1000000000"` (string format in WebSocket, numeric in REST)
- Response action names include the `subscribe_` prefix: `"subscribe_depth"` for the initial snapshot, `"subscribe_depth_update"` for subsequent changes

### Subscribe to Your Orders

```json
{
  "action": "subscribe_orders",
  "identities": [
    {
      "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
    }
  ]
}
```

**Response:**
```json
{
  "action": "subscribe_orders",
  "orders": [
    {
      "order_id": "0x1122334455667788...",
      "owner": {
        "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
      },
      "side": "Buy",
      "order_type": "Spot",
      "market_id": "0x09c17f...",
      "quantity": "5000000000",
      "quantity_fill": "2000000000",
      "price": "100000000",
      "price_fill": "100000000",
      "timestamp": "1734876543",
      "close": false,
      "partially_filled": true,
      "cancel": false,
      "history": [],
      "fills": []
    }
  ],
  "onchain_timestamp": "1734876543",
  "seen_timestamp": "1734876544"
}
```

**Order fields:**

| Field | Description |
|-------|-------------|
| `order_id` | Unique order identifier |
| `side` | `"Buy"` or `"Sell"` |
| `quantity` | Original order quantity |
| `quantity_fill` | Quantity that has been filled so far |
| `price` | Order price |
| `close` | `true` if the order is fully filled or cancelled |
| `partially_filled` | `true` if partially filled |
| `cancel` | `true` if the order was cancelled |

### Subscribe to Trades

```json
{
  "action": "subscribe_trades",
  "market_id": "0x09c17f779eb0a7658424e48935b2bef24013766f8b3da757becb2264406f9e96"
}
```

**Response:**
```json
{
  "action": "trades",
  "trades": [
    {
      "trade_id": "12345",
      "side": "Buy",
      "total": "500000000000",
      "quantity": "5000000000",
      "price": "100000000",
      "timestamp": "1734876543"
    }
  ],
  "market_id": "0x09c17f...",
  "onchain_timestamp": "1734876543",
  "seen_timestamp": "1734876544"
}
```

### Subscribe to Balances

```json
{
  "action": "subscribe_balances",
  "identities": [
    {
      "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
    }
  ]
}
```

**Response:**
```json
{
  "action": "subscribe_balances",
  "balance": [
    {
      "identity": {
        "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
      },
      "asset_id": "0xf6e5d4c3b2a1...",
      "total_locked": "2000000000",
      "total_unlocked": "5000000000",
      "trading_account_balance": "25000000000",
      "order_books": {
        "0x9ad52fb8...": {
          "locked": "2000000000",
          "unlocked": "5000000000"
        }
      }
    }
  ],
  "onchain_timestamp": "1734876543",
  "seen_timestamp": "1734876544"
}
```

### Subscribe to Nonce Updates

Useful for tracking nonce changes in real-time instead of polling:

```json
{
  "action": "subscribe_nonce",
  "identities": [
    {
      "ContractId": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f"
    }
  ]
}
```

**Response:**
```json
{
  "action": "nonce",
  "contract_id": "0xd3ca0cbf374bb2e11e1f554b088e0bc6b168b251b820a42a69624be4aa2f2a2f",
  "nonce": "5",
  "onchain_timestamp": "1734876543",
  "seen_timestamp": "1734876544"
}
```

### Unsubscribe

Each subscription type has a corresponding unsubscribe action:

| Unsubscribe Action | Required Fields |
|---------------------|-----------------|
| `unsubscribe_depth` | `market_id` |
| `unsubscribe_depth_view` | `market_id` |
| `unsubscribe_orders` | (none) |
| `unsubscribe_trades` | `market_id` |
| `unsubscribe_balances` | `identities` |
| `unsubscribe_nonce` | `identities` |

### Identity Format

WebSocket subscriptions use identities in one of two forms:
- Wallet address: `{"Address": "0x<b256_address>"}`
- Trading account: `{"ContractId": "0x<contract_id>"}`

For bot developers, always use `{"ContractId": "0x<trade_account_id>"}`.

---

## 6. Order Types Reference

| Order Type | Format | Fills Immediately | Rests on Book | Reverts If | Best For |
|-----------|--------|-------------------|---------------|------------|----------|
| **Spot** | `"Spot"` | Yes (partial) | Yes (remainder) | - | Standard limit orders |
| **Market** | `"Market"` | Yes | No (auto-cancelled) | - | Quick execution at current price |
| **Limit** | `{"Limit":["<price>","<timestamp>"]}` | Yes (at price or better) | Yes | - | Guaranteed price with priority |
| **FillOrKill** | `"FillOrKill"` | Yes (must fill completely) | No | Not fully filled | Atomic large orders |
| **PostOnly** | `"PostOnly"` | No | Yes | Any taker fill | Guaranteed maker execution |
| **BoundedMarket** | `{"BoundedMarket":{"max_price":"...","min_price":"..."}}` | Yes (within bounds) | No | Price outside bounds | Market orders with slippage protection |

### JSON Format for Each Order Type

**Spot** (standard limit order):
```json
{
  "CreateOrder": {
    "side": "Buy",
    "price": "100000000",
    "quantity": "5000000000",
    "order_type": "Spot"
  }
}
```

**Market** (immediate execution, no resting):
```json
{
  "CreateOrder": {
    "side": "Sell",
    "price": "100000000",
    "quantity": "5000000000",
    "order_type": "Market"
  }
}
```

**Limit** (price-time priority):
```json
{
  "CreateOrder": {
    "side": "Buy",
    "price": "100000000",
    "quantity": "5000000000",
    "order_type": {
      "Limit": ["100000000", "1734876543210"]
    }
  }
}
```
- First element: price level (u64 as string)
- Second element: timestamp for priority ordering (u64 as string)

**FillOrKill** (all or nothing):
```json
{
  "CreateOrder": {
    "side": "Buy",
    "price": "100000000",
    "quantity": "5000000000",
    "order_type": "FillOrKill"
  }
}
```

**PostOnly** (maker only):
```json
{
  "CreateOrder": {
    "side": "Buy",
    "price": "90000000",
    "quantity": "5000000000",
    "order_type": "PostOnly"
  }
}
```

**BoundedMarket** (market order with price bounds):
```json
{
  "CreateOrder": {
    "side": "Buy",
    "price": "100000000",
    "quantity": "5000000000",
    "order_type": {
      "BoundedMarket": {
        "max_price": "110000000",
        "min_price": "90000000"
      }
    }
  }
}
```
- `max_price` must be >= `min_price`

---

## 7. Complete REST API Reference

All endpoints are prefixed with `/v1/`. All responses are JSON.

### Market Data

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/v1/markets` | List all markets | No |
| GET | `/v1/markets/summary?market_id=0x...` | 24-hour market statistics | No |
| GET | `/v1/markets/ticker?market_id=0x...` | Real-time ticker data | No |

### Order Book & Depth

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/v1/depth?market_id=0x...&precision=10` | Order book depth (bids/asks) | No |

**Valid precisions:** 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000

### Trading Data

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/v1/trades?market_id=...&direction=desc&count=50` | Recent trade history | No |
| GET | `/v1/trades_by_account?market_id=...&contract=...&direction=desc&count=50` | Trades by account | No |
| GET | `/v1/bars?market_id=...&from=...&to=...&resolution=1h` | OHLCV candlestick data | No |

**Trades:** Max 50 per request. `direction` (`"asc"` or `"desc"`, lowercase) and `count` are **required** parameters. Pagination via `start_timestamp` + `start_trade_id`.

**Bars resolutions:** `1s`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`, `1w`, `1M`. Max 5000 bars per request.

### Account & Balance

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/v1/accounts` | Create a trading account | No |
| GET | `/v1/accounts?owner=0x...` | Get account info (by owner) | No |
| GET | `/v1/accounts?trade_account_id=0x...` | Get account info (by ID) | No |
| GET | `/v1/balance?asset_id=0x...&contract=0x...` | Get asset balance | No |

**Account lookup:** Provide exactly one of `owner`, `owner_contract`, or `trade_account_id`.

**Balance lookup:** Provide either `address` OR `contract` (not both).

### Order Management

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| GET | `/v1/orders?market_id=...&contract=...&direction=desc&count=20` | Get order history | No |
| GET | `/v1/order?market_id=...&order_id=...` | Get single order | No |

**Orders:** Max 200 per request. Use `is_open=true` to filter for open orders only. Pagination via `start_timestamp` + `start_order_id`.

### Session Management

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| PUT | `/v1/session` | Create/update trading session | `O2-Owner-Id` header + owner signature |
| POST | `/v1/session/actions` | Execute trading actions | `O2-Owner-Id` header + session signature |

### Account Operations

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/v1/accounts/actions` | Execute owner contract calls (batch) | `O2-Owner-Id` header + owner signature |
| POST | `/v1/accounts/upgrade` | Upgrade account contract | `O2-Owner-Id` header + owner signature |
| POST | `/v1/accounts/withdraw` | Withdraw assets | `O2-Owner-Id` header + owner signature |

**`O2-Owner-Id` header:** Required on all session and account operation endpoints (`PUT /v1/session`, `POST /v1/session/actions`, `POST /v1/accounts/withdraw`, `POST /v1/accounts/actions`, `POST /v1/accounts/upgrade`). The value is the **owner wallet's B256 address** (not the trade_account_id). This header is used for rate limiting and request validation. Missing it will result in a 400 error.

### Analytics Endpoints

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| POST | `/analytics/v1/whitelist` | Whitelist a trading account for order book access | No |
| GET | `/analytics/v1/referral/code-info?code=...` | Look up referral code info | No |

### Aggregator Endpoints

Alternative interface using `market_pair` notation (e.g., `FUEL_USDC`) instead of hex market IDs.

| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/aggregated/assets` | List all trading assets |
| GET | `/v1/aggregated/orderbook?market_pair=FUEL_USDC&depth=500&level=2` | Order book depth |
| GET | `/v1/aggregated/summary` | 24-hour stats for all pairs |
| GET | `/v1/aggregated/ticker` | Real-time ticker for all pairs |
| GET | `/v1/aggregated/trades?market_pair=FUEL_USDC` | Recent trades for a pair |

### System

| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/ws` | WebSocket endpoint (upgrade to WS) |

---

## 8. Error Handling

> **Note (Devnet):** The structured error codes (1xxx-8xxx) documented below are being rolled out progressively. On devnet, you may receive older-format error responses (plain text or different JSON structure). Always check the HTTP status code as the primary error indicator. The structured error codes below apply to testnet and mainnet.

### Error Response Format

Most API and WebSocket errors use this structure:

```json
{
  "code": 2000,
  "message": "Market 0x123... not found"
}
```

For `POST /v1/session/actions`, there are two error formats:

**Pre-flight validation error** (includes `code` field):
```json
{
  "code": 4000,
  "message": "Signature verification failed",
  "receipts": null,
  "reason": "detailed error description"
}
```

**On-chain revert error** (NO `code` field):
```json
{
  "message": "Revert(18446744073709486080)",
  "reason": "NotEnoughBalance",
  "receipts": [...]
}
```

**Detecting success vs. failure for `POST /v1/session/actions`:**
- **Success:** Response contains `tx_id` field
- **Pre-flight error:** Response contains `code` and `message` fields
- **On-chain revert:** Response contains `message` and optionally `reason` and `receipts`, but NO `code` field

The reliable check is: `"tx_id" in response` for success, `"message" in response and "tx_id" not in response` for any error. Do NOT rely on checking for `"code"` alone, as on-chain reverts omit it.

### Complete Error Code Table

| Code | Name | Description | How to Handle |
|------|------|-------------|---------------|
| **General (1xxx)** | | | |
| 1000 | InternalError | Unexpected server error | Retry with exponential backoff |
| 1001 | InvalidRequest | Malformed or invalid request | Check request format |
| 1002 | ParseError | Failed to parse request body | Validate JSON body |
| 1003 | RateLimitExceeded | Too many requests | Implement exponential backoff |
| 1004 | GeoRestricted | Region not allowed | Use an allowed region |
| **Market (2xxx)** | | | |
| 2000 | MarketNotFound | Market does not exist | Verify `market_id` from `/v1/markets` |
| 2001 | MarketPaused | Market is currently paused | Wait and retry later |
| 2002 | MarketAlreadyExists | Market already exists | Use the existing market |
| **Order (3xxx)** | | | |
| 3000 | OrderNotFound | Order does not exist | Verify `order_id` |
| 3001 | OrderNotActive | Order is not in active state | Order may already be filled/cancelled |
| 3002 | InvalidOrderParams | Invalid order parameters | Check price, quantity, order_type |
| **Account/Session (4xxx)** | | | |
| 4000 | InvalidSignature | Signature verification failed | Check signing logic (personalSign vs raw) |
| 4001 | InvalidSession | Session is invalid or expired | Create a new session |
| 4002 | AccountNotFound | Trading account not found | Create a trading account first |
| 4003 | WhitelistNotConfigured | Whitelist not configured | Include `contract_ids` in session |
| **Trade (5xxx)** | | | |
| 5000 | TradeNotFound | Trade does not exist | Verify trade ID |
| 5001 | InvalidTradeCount | Invalid trade count | Use count <= 50 |
| **Subscription/WebSocket (6xxx)** | | | |
| 6000 | AlreadySubscribed | Already subscribed to this topic | Skip -- you are already receiving updates |
| 6001 | TooManySubscriptions | Subscription limit exceeded | Reduce identities to <= 10 per request |
| 6002 | SubscriptionError | General subscription error | Check market_id format |
| **Validation (7xxx)** | | | |
| 7000 | InvalidAmount | Invalid amount specified | Check amount is positive and scaled |
| 7001 | InvalidTimeRange | Invalid time range | Check from/to timestamps |
| 7002 | InvalidPagination | Invalid pagination params | Use both start_timestamp + start_id together |
| 7003 | NoActionsProvided | No actions in request | Include at least one action |
| 7004 | TooManyActions | Too many actions (max 5) | Split into multiple requests |
| **Block/Events (8xxx)** | | | |
| 8000 | BlockNotFound | Block not found | Block may not be indexed yet |
| 8001 | EventsNotFound | Events not found for block | Events may not be indexed yet |

### Common Error Scenarios and Solutions

**Error 4000 on session creation:** The server recovered a different address from your signature. Common causes (check in order):
1. **Double-hashing (most common in JS/TS):** Your library's `sign()` internally hashes the digest again. In `@noble/secp256k1` v3, pass `prehash: false`. In other libraries, ensure you are passing a raw 32-byte digest, not a message that gets hashed again.
2. **Wrong signing method:** Session creation requires `personalSign` (with Fuel's `\x19Fuel Signed Message:\n` prefix). If you used raw `sign(sha256(bytes))`, the prefix is missing.
3. **Wrong private key:** Verify you are signing with the owner wallet key, not the session wallet key.
4. **Debugging tip:** Log the hex of your 32-byte digest before signing and compare it byte-for-byte against a known-good implementation. The error message "invalid fuel address provided" means the server recovered a valid but *different* address — any of the above would cause this.

**Error 4000 on session/actions:** Same root causes as above apply. Additionally:
1. Session actions use raw `sign(sha256(bytes))` — do NOT use `personalSign`.
2. Verify you are signing with the **session** wallet key, not the owner wallet key.
3. Verify the signing bytes are constructed correctly (nonce, expiry, actions must match exactly).

**Error 7004 on session/actions:** You have more than 5 actions in a single request. Split into multiple requests.

**Error 4001 on session/actions:** Your session has expired or was never created. Create a new session via `PUT /v1/session`.

---

## 9. Common Bot Patterns

### Testnet Market Conditions

On testnet, devnet, and sandbox, order books may be very thin or entirely one-sided (e.g., no bids). Before placing orders, check `GET /v1/depth?market_id=0x...&precision=10` to see current book state. If you place a market-crossing order into a thin book, it may fill at an unexpected price (e.g., a buy at 0.01 could fill against a hidden sell at 0.002). Use limit orders with reasonable prices based on the depth response to avoid surprises during testing.

### Market Maker Skeleton

A basic market maker places symmetric buy and sell orders around a reference price, cancelling stale orders and replacing them atomically each cycle:

```
function market_maker_loop(session, market, config):
    active_buy_id  = null
    active_sell_id = null

    while true:
        // 1. Get reference price from external source (e.g., Binance, Bitget)
        ref_price = fetch_external_price(config.price_source)

        // 2. Calculate spread prices
        buy_price  = ref_price * (1 - config.spread_pct)   // e.g., 2% below
        sell_price = ref_price * (1 + config.spread_pct)   // e.g., 2% above

        // 3. Scale to blockchain integers
        buy_price_scaled  = scale_and_truncate(buy_price, market.quote.decimals, market.quote.max_precision)
        sell_price_scaled = scale_and_truncate(sell_price, market.quote.decimals, market.quote.max_precision)
        quantity_scaled   = calculate_base_quantity(config.order_usd_value, ref_price, market.base.decimals, market.base.max_precision)

        // 4. Fetch current nonce
        nonce = fetch_nonce(session.trade_account_id)

        // 5. Build actions: cancel stale orders + settle + place new orders
        //    Max 5 actions per request — cancel(buy) + cancel(sell) + settle + create(buy) + create(sell)
        action_list = []

        if active_buy_id != null:
            action_list.append({ CancelOrder: { order_id: active_buy_id } })
        if active_sell_id != null:
            action_list.append({ CancelOrder: { order_id: active_sell_id } })

        action_list.append({ SettleBalance: { to: { ContractId: session.trade_account_id } } })
        action_list.append({ CreateOrder: { side: "Buy",  price: buy_price_scaled,  quantity: quantity_scaled, order_type: "Spot" } })
        action_list.append({ CreateOrder: { side: "Sell", price: sell_price_scaled, quantity: quantity_scaled, order_type: "Spot" } })

        actions = [{ market_id: market.market_id, actions: action_list }]

        // 6. Sign and submit (collect_orders: true to get new order IDs)
        bytes     = build_actions_signing_bytes(nonce, encode_calls(actions))
        signature = secp256k1_sign(session.private_key, sha256(bytes))
        result    = post("/v1/session/actions", { actions, signature, nonce, ..., collect_orders: true })

        if result.success:
            nonce = nonce + 1
            // 7. Track new order IDs for next cycle's cancellation
            active_buy_id  = null
            active_sell_id = null
            for order in result.orders:
                if order.side == "Buy":
                    active_buy_id = order.order_id
                else:
                    active_sell_id = order.order_id

        // 8. Wait for next cycle
        sleep(config.cycle_interval_seconds)
```

> **Why cancel+replace atomically?** Because all actions in a single `session/actions` request execute in one on-chain transaction, your old orders are cancelled and new orders are placed simultaneously. There is never a moment where you have nothing on the book. This uses all 5 of the max allowed actions per request (2 cancels + 1 settle + 2 creates). On the first cycle (no prior orders), only 3 actions are needed (settle + 2 creates).
>
> **Handling filled orders:** If an order was fully filled between cycles, attempting to cancel it will cause the entire transaction to revert (including the new order placements). Use `subscribe_orders` over WebSocket (see Section 5) to receive real-time fill notifications — when an order update arrives with `close: true`, set the corresponding `active_buy_id` or `active_sell_id` to null so the next cycle skips the cancel. For partial fills, `quantity_fill` tells you how much has been filled while the order remains open (cancellable). As a fallback, if a batch fails due to a stale cancel, remove the failed cancel from the action list and resubmit.

### Simple Taker / Sniper

A taker bot monitors the order book for favorable prices and executes immediately:

```
function taker_loop(session, market, config):
    // 1. Connect to WebSocket for real-time depth
    ws = connect("wss://api.devnet.o2.app/v1/ws")
    ws.send({ action: "subscribe_depth", market_id: market.market_id, precision: "1" })

    // 2. Listen for depth updates
    while message = ws.receive():
        if message.action != "subscribe_depth" and message.action != "subscribe_depth_update":
            continue

        best_ask = message.view.sells[0]  // {"price": "...", "quantity": "..."}
        best_bid = message.view.buys[0]   // {"price": "...", "quantity": "..."}

        // 3. Check if price meets our target
        if best_ask.price <= config.buy_below_price:
            // Execute a bounded market buy with slippage protection
            nonce = fetch_nonce(session.trade_account_id)
            actions = [{
                market_id: market.market_id,
                actions: [{
                    CreateOrder: {
                        side: "Buy",
                        price: best_ask.price,
                        quantity: config.max_quantity,
                        order_type: {
                            BoundedMarket: {
                                max_price: best_ask.price * 1.005,  // 0.5% slippage
                                min_price: "0"
                            }
                        }
                    }
                }]
            }]

            sign_and_submit(session, nonce, actions)
```

### Order Management Best Practices

1. **Always settle before creating orders.** Include `SettleBalance` as the first action to ensure filled order proceeds are available.

2. **Track nonces locally.** Increment nonce after each `session/actions` call (success or failure -- the nonce increments on-chain even on reverts). Re-fetch from the API on any unexpected state.

3. **Use `collect_orders: true`** to get order IDs in the response, enabling immediate tracking.

4. **Subscribe to WebSocket for real-time updates.** Use `subscribe_orders` and `subscribe_balances` instead of polling REST endpoints.

5. **Handle partial fills.** Check `quantity_fill` vs `quantity` to know how much has been filled.

6. **Batch operations.** Cancel old orders and place new ones in a single `session/actions` call to minimize latency and nonce conflicts.

7. **Implement exponential backoff** for rate-limited responses (error code 1003).

8. **Renew sessions before expiry.** Track your session's `expiry` timestamp and create a new session before it expires (requires owner key).

---

## 10. Appendix

### Fuel ABI Encoding Rules

O2 uses Fuel's ABI encoding (v1, fuels SDK 0.76+) for all signing byte construction. Understanding these rules is essential for extending the bot to support new contract methods.

**Encoding rules:**

| Type | Encoding | Size |
|------|----------|------|
| `u64` | 8 bytes big-endian | 8 bytes |
| `Enum` | `u64(discriminant)` + tightly packed variant payload (NO padding to largest variant) | 8 + variant payload |
| `Identity` | `u64(0)` for Address or `u64(1)` for ContractId, + 32 raw bytes | 40 bytes |
| `Option<T>` | `u64(0)` for None, `u64(1)` + `encoded(T)` for Some | 8 bytes (None) or 8 + sizeof(T) |
| `Vec<T>` | `u64(length)` + N * `encoded(T)` | 8 + N * sizeof(T) |
| `Struct` | Fields concatenated in declaration order, no padding | sum of field sizes |
| `ContractId` / `Address` | 32 raw bytes (no discriminant when used directly, only when inside Identity) | 32 bytes |
| Function selector | `u64(name_length)` + `utf8(name)` — NOT hash-based | 8 + name length |

**Python reference implementation of the encoding primitives:**
```python
import struct

def u64_be(value: int) -> bytes:
    """Encode an integer as 8 bytes big-endian (u64)."""
    return struct.pack(">Q", value)

def encode_identity(discriminant: int, address_bytes: bytes) -> bytes:
    """Encode a Fuel Identity enum.
    discriminant: 0 = Address, 1 = ContractId
    """
    return u64_be(discriminant) + address_bytes

def encode_option_some(data: bytes) -> bytes:
    """Encode Option::Some(data): u64(1) + data."""
    return u64_be(1) + data

def encode_option_none() -> bytes:
    """Encode Option::None: u64(0)."""
    return u64_be(0)

def encode_option_call_data(data_or_none) -> bytes:
    """Encode Option for call_data in action signing bytes.
    None  -> u64(0)
    Some  -> u64(1) + u64(len(data)) + data
    """
    if data_or_none is None:
        return u64_be(0)
    return u64_be(1) + u64_be(len(data_or_none)) + data_or_none

def function_selector(name: str) -> bytes:
    """Encode a Fuel ABI function selector: u64_be(len(name)) + utf8(name)."""
    name_bytes = name.encode("utf-8")
    return u64_be(len(name_bytes)) + name_bytes
```

### Decimal Handling Rules

O2 uses fixed-point integer arithmetic. All prices and quantities are scaled integers with a configurable number of decimal places (typically 9).

**Conversion formulas:**

| Direction | Formula | Example |
|-----------|---------|---------|
| Human to chain | `chain_value = human_value * 10^decimals` | `1.5 USDC -> 1500000000` |
| Chain to human | `human_value = chain_value / 10^decimals` | `1500000000 -> 1.5 USDC` |

**Precision truncation:**
After scaling, truncate to `max_precision` meaningful decimal places using the truncation factor:
```
truncate_factor = 10^(decimals - max_precision)
truncated = floor(scaled / truncate_factor) * truncate_factor
```

`max_precision` determines how many of the `decimals` places are significant. For example, with `decimals=9` and `max_precision=3`, only the top 3 decimal places matter -- the remaining 6 are zeroed out by the truncation factor (`10^6 = 1000000`).

For quantities, use `ceil()` instead of `floor()` to avoid rounding to zero for small orders.

**Note:** `max_precision` values are per-market and per-asset. Always read them from `GET /v1/markets` rather than hardcoding. Values may differ between devnet, testnet, and mainnet.

**Example decimal configurations (illustrative -- always verify via API):**

| Asset | Decimals | Max Precision | Truncate Factor | Example |
|-------|----------|---------------|-----------------|---------|
| ETH | 9 | 3 | `10^6 = 1000000` | `1.234 ETH -> 1234000000` |
| USDC | 9 | 9 | `10^0 = 1` | `2500.500000001 USDC -> 2500500000001` |
| FUEL | 9 | 3 | `10^6 = 1000000` | `0.084 FUEL -> 84000000` |

**Amount calculation for CreateOrder:**
When creating an order, the blockchain needs to know how many tokens to forward:
```python
if side == "Buy":
    # Forward quote tokens: (price * quantity) / 10^base_decimals
    amount = (price * quantity) // (10 ** base_decimals)
else:  # Sell
    # Forward base tokens: quantity
    amount = quantity
```

### Fee Structure

Fee values are returned per-market in the `GET /v1/markets` response as raw integer strings (`maker_fee` and `taker_fee`). These values represent **basis points** (1 basis point = 0.01%). A value of `"100"` means 100 basis points = 1.00%. A value of `"0"` means no fee.

**Example from devnet:**
```json
{
  "maker_fee": "0",
  "taker_fee": "100"
}
```
This means: maker fee is 0% (free), taker fee is 100 basis points (1.00%).

**Note:** Fee values are per-market and may differ between environments. Always read them from the `GET /v1/markets` response rather than hardcoding.

| Fee Type | Description |
|----------|-------------|
| Maker | Orders that rest on the book (add liquidity) |
| Taker | Orders that execute immediately (remove liquidity) |
| Gas | O2 sponsors gas fees for API-submitted transactions |

Fees are determined by execution behavior, not order type:
- An order that enters storage (rests on book) = maker fee
- An order that fills immediately = taker fee
- A partially filled order pays taker fee on the filled portion and maker fee on the resting portion

### Rate Limit Summary

| Endpoint Category | Limit Details |
|-------------------|---------------|
| Session endpoints | Per-owner rate limiting via `O2-Owner-Id` header. For `POST /v1/session/actions`, maintain at least a 2-3 second delay between submissions. Rapid-fire submissions (4+ within a few seconds) will trigger rate limiting. When rate-limited, wait 3-5 seconds before retrying with a re-fetched nonce. |
| Trades query | Max 50 items per request |
| Orders query | Max 200 items per request |
| Bars (OHLCV) | Max 5000 bars per request |
| Session actions | Max 5 actions, max 5 markets per request |
| Variable outputs | Max 10 per session/actions request |
| WebSocket subscriptions | Max 10 identities per subscription request |
| Faucet (testnet/devnet/sandbox only) | 60-second cooldown per wallet/contract |

When you receive error code 1003 (RateLimitExceeded), implement exponential backoff with jitter before retrying.

### Session Expiry Guidelines

- **Minimum:** 1 hour from current time (enforced by the API)
- **Default:** 30 days (recommended for bots)
- **Maximum:** No hard limit, but shorter is more secure
- **Expiry format:** Unix timestamp in **seconds** (not milliseconds)

To calculate a 30-day expiry:
```
expiry = current_unix_timestamp_seconds + (30 * 24 * 60 * 60)
```

For signing implementation questions, consult the Python reference implementations and byte layout descriptions in this guide or contact O2 support.
