Skip to content
🌐Network: Mainnet

Session Keys

Session keys enable automated trading on O2 without exposing your owner wallet's private key. Instead of signing every action with your main wallet, you delegate temporary signing authority to a lightweight session keypair.

Overview

O2's session key system follows a two-key architecture:

KeyPurposeStorageLifetime
Owner KeyCreates sessions, withdraws funds, manages accountHardware wallet / secure storagePermanent
Session KeyExecutes trades, cancels orders, settles balancesApplication memory / env variableTemporary (configurable expiry)

The owner key signs a single transaction to create a session, then the session key handles all subsequent trading operations. This means your owner wallet can remain safely offline while your trading bot or application operates independently.

Key Relationship

┌─────────────────────────────────────────────────┐
│                   Owner Wallet                  │
│            (Fuel / EVM wallet)                  │
│                                                 │
│  ► Signs session creation (one-time)            │
│  ► Can revoke session at any time               │
│  ► Required for withdrawals                     │
│                                                 │
│    ┌─────────────────────────────────────────┐   │
│    │          Session Key                    │   │
│    │   (randomly generated keypair)          │   │
│    │                                         │   │
│    │  ► Signs trade actions                  │   │
│    │  ► Limited to approved contracts        │   │
│    │  ► Auto-expires after set duration      │   │
│    │  ► Cannot withdraw funds                │   │
│    └─────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

Security Model

  • One session per account: Setting a new session automatically revokes the previous one
  • Contract restrictions: Sessions must specify which order book contracts they can interact with
  • Time-bound: Sessions have an expiry timestamp enforced on-chain
  • Non-custodial: Session keys cannot withdraw funds from the trading account
  • Revocable: The owner can revoke a session at any time by calling set_session with None

Supported Signature Types

O2 supports three signature schemes for session operations. The signature type is specified in the signature field of API requests:

Secp256k1 (ECDSA)

The default signature scheme for Fuel wallets. This is the most common choice for server-side trading bots and applications using the fuels TypeScript SDK.

json
{
  "signature": {
    "Secp256k1": "0x..."
  }
}

Use when: Building with Fuel wallets, server-side bots, or the fuels TypeScript SDK.

Secp256r1 (P-256 / NIST)

The NIST standard elliptic curve, supported by WebAuthn, Apple's Secure Enclave, and hardware security modules. Enables passkey-based signing for browser applications.

json
{
  "signature": {
    "Secp256r1": "0x..."
  }
}

Use when: Building browser-based applications with passkey/WebAuthn support, or integrating with hardware security modules.

Ed25519

High-performance EdDSA signature scheme using Curve25519. Offers fast signing and verification with compact signatures.

json
{
  "signature": {
    "Ed25519": "0x..."
  }
}

Use when: Performance-sensitive applications or integrating with systems that use Ed25519 natively.

On-Chain Verification

All signatures are verified on-chain by the trade-account contract. For Fuel addresses, the contract uses the personal sign format:

sha256( "\x19Fuel Signed Message:\n" + length + message )

For EVM-compatible addresses (detected by a 12-byte zero prefix), it uses the Ethereum personal sign format:

keccak256( "\x19Ethereum Signed Message:\n" + length + message )

The recovered address is compared against either the owner address (for session creation) or the session key address (for action execution).

Session Lifecycle

1. Create a Trading Account

Before creating a session, you need a trading account. This is a smart contract wallet deployed for your address:

typescript
const accountRes = await fetch(new URL('./v1/accounts', O2_API), {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    identity: { Address: ownerAddress },
  }),
});
const { trade_account_id } = await accountRes.json();

2. Generate a Session Keypair

Generate a random keypair that will serve as your session key:

typescript
import { Signer } from 'fuels';

// Generate a random session key
const sessionPrivateKey = Signer.generatePrivateKey();
const sessionSigner = new Signer(sessionPrivateKey);

console.log('Session Address:', sessionSigner.address.toB256());

TIP

Store the session private key securely (e.g., in an environment variable). You'll need it for all subsequent trading operations during the session's lifetime.

3. Create a Session (Owner Signs)

The owner wallet signs a message authorizing the session key. This is the only step that requires the owner's private key:

typescript
// Fetch the current nonce from the trade account contract
const tradeAccount = new Contract(tradeAccountId, TRADE_ACCOUNT_ABI, provider);
const { value: nonce } = await tradeAccount.functions.get_nonce().get();

// Fetch all market contract IDs (session must specify allowed contracts)
const marketsRes = await fetch(new URL('./v1/markets', O2_API));
const { markets } = await marketsRes.json();
const contractIds = markets.map((m) => m.contract_id);

// Create session parameters
const session = {
  session_id: { Address: { bits: sessionSigner.address.toB256() } },
  expiry: { unix: bn((Date.now() + 24 * 60 * 60 * 1000).toString()) }, // 24 hours
  contract_ids: contractIds.map((id) => ({ bits: id })),
};

Build the bytes to sign — the owner signs a structured message containing the nonce, chain ID, function name, and session parameters:

typescript
// Encode the session data using ABI coders
const sessionArgBytes = SESSION_OPTION_CODER.encode(session);

// Construct the full message: nonce + chainId + funcNameLen + funcName + args
const bytesToSign = concat([
  new BigNumberCoder('u64').encode(nonce),
  new BigNumberCoder('u64').encode(chainId),
  new BigNumberCoder('u64').encode(toUtf8Bytes('set_session').length),
  toUtf8Bytes('set_session'),
  sessionArgBytes,
]);

// Sign using personal sign format
const messageHash = hashPersonalMessage(bytesToSign);
const signature = ownerSigner.sign(messageHash);

Submit to the API:

typescript
const sessionRes = await fetch(new URL('./v1/session', O2_API), {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'O2-Owner-Id': ownerAddress,
  },
  body: JSON.stringify({
    nonce: nonce.toString(),
    contract_id: tradeAccountId,
    session_id: { Address: sessionSigner.address.toB256() },
    contract_ids: contractIds,
    signature: { Secp256k1: signature },
    expiry: session.expiry.unix.toString(),
  }),
});

4. Execute Actions (Session Key Signs)

Once the session is active, use the session key to sign trading actions. The session key signs a sha256 hash of the action data (not personal sign format):

typescript
// Increment the nonce (session creation used one)
const orderNonce = nonce.add(1);

// Encode the contract call
const callBytes = callContractToBytes({
  contractId: market.contract_id,
  functionSelector: hexlify(encodeFunctionSelector('create_order')),
  amount: forwardAmount,
  assetId: forwardAssetId,
  gas: GAS_LIMIT_DEFAULT,
  args: orderArgBytes,
});

// Sign with session key: sha256(nonce + numCalls + callBytes)
const sessionBytesToSign = concat([
  new BigNumberCoder('u64').encode(orderNonce),
  new BigNumberCoder('u64').encode(1), // number of calls
  callBytes,
]);
const sessionSignature = sessionSigner.sign(sha256(sessionBytesToSign));

Submit via session actions API:

typescript
const orderRes = await fetch(new URL('./v1/session/actions', O2_API), {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'O2-Owner-Id': ownerAddress,
  },
  body: JSON.stringify({
    actions: [{
      market_id: market.market_id,
      actions: [{
        CreateOrder: {
          side: 'Buy',
          order_type: 'Spot',
          price: price.toString(),
          quantity: quantity.toString(),
        },
      }],
    }],
    signature: { Secp256k1: sessionSignature },
    nonce: orderNonce.toString(),
    trade_account_id: tradeAccountId,
    session_id: { Address: sessionSigner.address.toB256() },
    variable_outputs: 0,
    collect_orders: false,
  }),
});

Cancelling an Order

Cancelling an order follows the same signing flow but with different encoding:

  • Function selector: 'cancel_order' (not 'create_order')
  • Arguments: The order ID encoded as a b256 (32-byte hex string)
  • Forward amount: 0 — no assets are forwarded
  • Forward asset ID: The zero asset ID (0x0000...0000) — CancelOrder does not forward any assets, so it must use AssetId::default()
typescript
// Re-fetch the current nonce before signing
const { value: cancelNonce } = await tradeAccount.functions.get_nonce().get();

// Encode the order ID (must be a valid b256 hex string, 0x-prefixed)
const orderId = '0x0000000000001485000002ba7def300000000000000000000000000000000001';
const orderIdBytes = new B256Coder().encode(orderId);

const cancelCallBytes = callContractToBytes({
  contractId: market.contract_id,
  functionSelector: hexlify(encodeFunctionSelector('cancel_order')),
  amount: bn(0),
  assetId: '0x0000000000000000000000000000000000000000000000000000000000000000',
  gas: GAS_LIMIT_DEFAULT,
  args: orderIdBytes,
});

const cancelBytesToSign = concat([
  new BigNumberCoder('u64').encode(cancelNonce),
  new BigNumberCoder('u64').encode(1),
  cancelCallBytes,
]);
const cancelSignature = sessionSigner.sign(sha256(cancelBytesToSign));

Submit the cancel request:

typescript
const cancelRes = await fetch(new URL('./v1/session/actions', O2_API), {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'O2-Owner-Id': ownerAddress,
  },
  body: JSON.stringify({
    actions: [{
      market_id: market.market_id,
      actions: [{
        CancelOrder: {
          order_id: orderId,
        },
      }],
    }],
    signature: { Secp256k1: cancelSignature },
    nonce: cancelNonce.toString(),
    trade_account_id: tradeAccountId,
    session_id: { Address: sessionSigner.address.toB256() },
    variable_outputs: 0,
    collect_orders: true,
  }),
});

IMPORTANT

The CancelOrder encoding differs from CreateOrder in two critical ways:

  1. The forward asset ID must be the zero asset ID (0x0000...0000), not the market's base or quote asset. Using a non-zero asset ID will cause a signature verification failure.
  2. The arguments are a single b256 (the order ID), not the OrderArgs struct used by CreateOrder.

5. Session Expiry and Renewal

Sessions expire at the timestamp specified during creation. When a session expires:

  • The trade-account contract rejects any actions signed by the expired session key
  • The owner must create a new session (repeat step 3) with a fresh or reused keypair
  • The nonce continues incrementing — it does not reset when a session expires

Recommended expiry durations:

Use CaseDurationRationale
Interactive trading4–8 hoursAligns with active trading sessions
Automated bot24–48 hoursBalances security with uptime
High-frequency bot7 daysMinimizes session renewal overhead

IMPORTANT

Always monitor session expiry and renew proactively. If a session expires mid-operation, the action will be rejected on-chain.

Renewal pattern:

typescript
function shouldRenewSession(expiryTimestamp: number): boolean {
  const bufferMs = 5 * 60 * 1000; // 5 minute buffer
  return Date.now() + bufferMs >= expiryTimestamp;
}

Nonce Management

Each trading account maintains an on-chain nonce that increments with every session-authorized operation. Nonces prevent replay attacks — a signed message can only be used once.

How Nonces Work

  1. Fetch: Read the current nonce from the trade-account contract via get_nonce()
  2. Sign: Include the current nonce in the bytes you sign
  3. Submit: The contract verifies the nonce matches and increments it atomically
  4. Track: Your local nonce state must stay in sync with the on-chain value
typescript
// Fetch current nonce
const { value: nonce } = await tradeAccount.functions.get_nonce().get();

// Use nonce for session creation (owner sign)
// After session creation, nonce becomes nonce + 1

// Use nonce + 1 for first action (session sign)
// After first action, nonce becomes nonce + 2
// ... and so on

Nonce Synchronization

TIP

Best practice: Always re-fetch the nonce from the contract immediately before signing each action. While the simple nonce.add(1) pattern works for sequential scripts, production applications should never assume the nonce value — other operations, concurrent requests, or retries can cause the local counter to drift.

If your local nonce gets out of sync (e.g., after a crash, concurrent operations, or a failed transaction that still incremented the nonce), always re-fetch from the contract:

typescript
async function getCurrentNonce(
  tradeAccountId: string,
  provider: Provider
): Promise<BN> {
  const tradeAccount = new Contract(tradeAccountId, TRADE_ACCOUNT_ABI, provider);
  const { value } = await tradeAccount.functions.get_nonce().get();
  return value;
}

WARNING

Never reuse a nonce. The contract will reject any action with a nonce that has already been consumed. If you receive a nonce error, re-fetch the current value from the contract.

bytesToSign Construction

Understanding how the signing payload is constructed is critical for implementing custom integrations. O2 uses two distinct signing formats depending on the operation.

Owner Signing (Session Creation)

Used for set_session, withdraw, and other owner-authenticated operations. The owner signs using the personal message format:

personalSign(
  nonce          (u64)   — current contract nonce
  chainId        (u64)   — Fuel chain ID
  funcNameLen    (u64)   — length of function name in bytes
  funcName       (bytes) — UTF-8 encoded function name (e.g., "set_session")
  args           (bytes) — ABI-encoded function arguments
)

The personal sign wraps the message as:

sha256( "\x19Fuel Signed Message:\n" + messageLength + message )

This is implemented by O2ActionsEncoder.createCallToSign():

typescript
static createCallToSign(
  nonce: BigNumberish,
  chainId: BigNumberish,
  invocationScope: FunctionInvocationScope<any>
) {
  // Encodes: nonce + chainId + funcNameLen + funcName + argBytes
  const funcNameBytes = toUtf8Bytes(callConfig.func.jsonFn.name);
  return concat([
    new BigNumberCoder('u64').encode(nonce),
    new BigNumberCoder('u64').encode(chainId),
    new BigNumberCoder('u64').encode(funcNameBytes.length),
    funcNameBytes,
    argBytes,
  ]);
}

Session Signing (Action Execution)

Used for session_call_contract and session_call_contracts. The session key signs a sha256 hash of the action data:

sha256(
  nonce          (u64)     — current contract nonce
  numCalls       (u64)     — number of contract calls (for batch)
  callBytes      (bytes[]) — encoded contract calls
)

Each contract call is encoded by callContractToBytes() as:

contractId          (b256)           — target contract
selectorLen         (u64)            — function selector byte length
functionSelector    (bytes)          — encoded selector
amount              (u64)            — forwarded asset amount
assetId             (b256)           — forwarded asset ID
gas                 (u64)            — gas limit
argsOption          (Option<bytes>)  — optional encoded arguments

This is implemented by O2ActionsEncoder.createCallsOutput() and encodeCallsBytes():

typescript
static encodeCallsBytes(
  nonce: BigNumberish,
  bytes: Uint8Array,
  length?: number
) {
  const byteToSign = [new BigNumberCoder('u64').encode(nonce)];
  if (length) {
    byteToSign.push(new BigNumberCoder('u64').encode(length));
  }
  byteToSign.push(bytes);
  return concat(byteToSign);
}

Signing Comparison

AspectOwner SignSession Sign
Hashsha256(prefix + len + message)sha256(message)
FormatPersonal message (with prefix)Raw sha256
Includesnonce + chainId + funcName + argsnonce + numCalls + callBytes
Used forSession creation, withdrawalsTrade execution, order cancellation

Complete TypeScript Example

Below is a complete end-to-end example demonstrating session key creation and order execution. For the full runnable script, see here.

Security Considerations

Key Storage

EnvironmentRecommended StorageNotes
Server / BotEnvironment variables or secrets managerNever commit keys to version control
BrowserIn-memory onlyGenerate fresh keys per browser session
MobileSecure enclave / keychainUse platform-native secure storage

CAUTION

Never log or expose session private keys. While session keys cannot withdraw funds, a compromised key could be used to place unauthorized trades until it expires or is revoked.

Expiry Best Practices

  • Set the shortest practical expiry for your use case
  • Implement proactive renewal — renew the session before it expires rather than reacting to failures
  • Use a 5-minute buffer before expiry to account for clock drift and network latency
  • For bots, consider a health check loop that monitors session validity

Contract ID Restrictions

When creating a session, you specify which order book contracts the session key can interact with. Best practices:

  • Include only the contracts you need — avoid using all available contracts unnecessarily
  • When a new market is added, you need to create a new session to include its contract ID
  • Verify contract IDs against the /v1/markets endpoint before creating a session

Revoking Compromised Sessions

If you suspect a session key is compromised, immediately revoke it by creating a new session or calling set_session with None:

typescript
// Option 1: Create a new session (automatically revokes the old one)
await api.createSession(ownerWallet, tradeAccountId, newSessionKey, ...);

// Option 2: Revoke without replacement (via direct API call)
// Call set_session with session = None

Additional Security Notes

  • Nonces are monotonic — they only increase and never reset, even across sessions
  • The owner key is required for all fund movements (deposits are direct transfers to the contract; withdrawals require owner signature)
  • Session operations emit on-chain events (SessionCreatedEvent, SessionRevokedEvent, SessionContractCallEvent) for auditability
  • The trade-account contract verifies that the target contract is in the session's allowed list before executing any call

Common Errors & Troubleshooting

Stale Nonce

Nonce in the request(812) is less than the nonce in the database(813)

Cause: The nonce you signed with is behind the on-chain nonce. This happens when a previous transaction incremented the nonce between when you fetched it and when you submitted.

Fix: Always re-fetch the nonce from the contract immediately before signing. Do not cache or increment nonces locally in production.

Invalid Session Signature

invalid session signature provided: invalid fuel address provided

Cause: The signed bytes do not match what the server expects. Common reasons:

MistakeCorrect Approach
Wrong forwardAssetId for CancelOrder (using market asset)Use the zero asset ID: 0x0000...0000
Wrong argument encoding for CancelOrder (using OrderArgs)Encode the order ID as a single b256
Stale or expired sessionCreate a new session via PUT /v1/session
Mismatched nonceRe-fetch the nonce before signing

Fix: Verify that the function selector, argument encoding, forward amount, and forward asset ID all match the action type.

PostOnly Order Rejected

transaction reverted: Revert(18446744073709486086)

With log output containing OrderMatchedEvent and OrderPartiallyFilled.

Cause: A PostOnly order was rejected because it would have immediately matched against an existing order on the book. PostOnly orders must rest on the book as a maker — they cannot be takers.

Fix:

  • For a Buy PostOnly order, set the price below the current lowest ask (sell) price
  • For a Sell PostOnly order, set the price above the current highest bid (buy) price
  • Use Spot or Market order type if you want immediate execution

Invalid Order Amount

transaction reverted: InvalidInputAmount

Cause: The order value is below the market's minimum order value (typically 1.0 USDC).

Fix: Increase the quantity so the total order value exceeds the minimum. For example, at a price of 1900 USDC/ETH:

QuantityValue (USDC)Valid?
400000 (0.0004 ETH)0.76Below minimum
1000000 (0.001 ETH)1.90Valid

Invalid b256

Error: Invalid b256

Cause: The value passed is not a valid b256 hex string. The B256Coder in the Fuel SDK expects a 0x-prefixed, 64-character hex string.

Fix: Ensure the order ID is 0x-prefixed. If the API returns the order ID without a prefix, prepend 0x:

typescript
const normalizedOrderId = orderId.startsWith('0x') ? orderId : `0x${orderId}`;

No Active Session

No session found

Cause: You are trying to execute session actions without first creating a session for the trading account.

Fix: Create a session via PUT /v1/session before calling POST /v1/session/actions. Ensure you are using the same owner address and trading account that the session was created for.