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:
| Key | Purpose | Storage | Lifetime |
|---|---|---|---|
| Owner Key | Creates sessions, withdraws funds, manages account | Hardware wallet / secure storage | Permanent |
| Session Key | Executes trades, cancels orders, settles balances | Application memory / env variable | Temporary (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_sessionwithNone
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.
{
"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.
{
"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.
{
"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:
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:
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:
// 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:
// 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:
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):
// 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:
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 useAssetId::default()
// 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:
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:
- 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. - The arguments are a single
b256(the order ID), not theOrderArgsstruct used byCreateOrder.
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 Case | Duration | Rationale |
|---|---|---|
| Interactive trading | 4–8 hours | Aligns with active trading sessions |
| Automated bot | 24–48 hours | Balances security with uptime |
| High-frequency bot | 7 days | Minimizes 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:
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
- Fetch: Read the current nonce from the trade-account contract via
get_nonce() - Sign: Include the current nonce in the bytes you sign
- Submit: The contract verifies the nonce matches and increments it atomically
- Track: Your local nonce state must stay in sync with the on-chain value
// 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 onNonce 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:
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():
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 argumentsThis is implemented by O2ActionsEncoder.createCallsOutput() and encodeCallsBytes():
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
| Aspect | Owner Sign | Session Sign |
|---|---|---|
| Hash | sha256(prefix + len + message) | sha256(message) |
| Format | Personal message (with prefix) | Raw sha256 |
| Includes | nonce + chainId + funcName + args | nonce + numCalls + callBytes |
| Used for | Session creation, withdrawals | Trade 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
| Environment | Recommended Storage | Notes |
|---|---|---|
| Server / Bot | Environment variables or secrets manager | Never commit keys to version control |
| Browser | In-memory only | Generate fresh keys per browser session |
| Mobile | Secure enclave / keychain | Use 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/marketsendpoint 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:
// 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 = NoneAdditional 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 providedCause: The signed bytes do not match what the server expects. Common reasons:
| Mistake | Correct 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 session | Create a new session via PUT /v1/session |
| Mismatched nonce | Re-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
SpotorMarketorder type if you want immediate execution
Invalid Order Amount
transaction reverted: InvalidInputAmountCause: 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:
| Quantity | Value (USDC) | Valid? |
|---|---|---|
| 400000 (0.0004 ETH) | 0.76 | Below minimum |
| 1000000 (0.001 ETH) | 1.90 | Valid |
Invalid b256
Error: Invalid b256Cause: 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:
const normalizedOrderId = orderId.startsWith('0x') ? orderId : `0x${orderId}`;No Active Session
No session foundCause: 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.