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.