External Signers Guide

For production deployments, you likely manage private keys in a secure enclave (hardware wallet, AWS KMS, Google Cloud KMS, HashiCorp Vault, etc.) rather than in-process. The O2 SDK supports this via the ExternalSigner and ExternalEvmSigner classes.

How it works

The SDK handles all message framing (prefix bytes, hashing) internally via the shared helpers fuel_personal_sign_digest() (Fuel) and evm_personal_sign_digest() (EVM). Your external signing function only needs to:

  1. Receive a 32-byte digest.

  2. Sign it with secp256k1 (ECDSA).

  3. Return a 64-byte Fuel compact signature.

Use to_fuel_compact_signature() to convert from standard (r, s, recovery_id) components.

Fuel-native external signer

For Fuel-native accounts:

from o2_sdk import O2Client, Network, OrderSide, ExternalSigner, to_fuel_compact_signature

def my_kms_sign(digest: bytes) -> bytes:
    """Sign a 32-byte digest using your KMS/HSM."""
    r, s, recovery_id = my_kms.sign(key_id="my-key", digest=digest)
    return to_fuel_compact_signature(r, s, recovery_id)

signer = ExternalSigner(
    b256_address="0x1234...abcd",  # Your Fuel B256 address
    sign_digest=my_kms_sign,
)

async with O2Client(network=Network.MAINNET) as client:
    account = await client.setup_account(signer)
    session = await client.create_session(
        owner=signer, markets=["FUEL/USDC"]
    )
    result = await client.create_order(
        "FUEL/USDC", OrderSide.BUY, 0.02, 100.0
    )

The SDK will call my_kms_sign with a Fuel-prefixed SHA-256 digest for session creation and withdrawals.

Important

Session actions (orders, cancels, settlements) are signed with the session key — not the external signer. The external signer is only needed for session creation and withdrawals.

EVM external signer

For EVM-compatible accounts (MetaMask, Ledger via Ethereum, etc.):

from o2_sdk import ExternalEvmSigner, to_fuel_compact_signature

signer = ExternalEvmSigner(
    b256_address="0x000000000000000000000000abcd...1234",
    evm_address="0xabcd...1234",
    sign_digest=my_kms_sign,  # Same callback interface
)

The only difference is the message framing: ExternalEvmSigner uses Ethereum’s \x19Ethereum Signed Message:\n prefix + keccak-256, while ExternalSigner uses Fuel’s \x19Fuel Signed Message:\n prefix + SHA-256.

Implementing the callback

The SignDigestFn callback must return a 64-byte Fuel compact signature. Here is how to build one from standard components:

from o2_sdk import to_fuel_compact_signature

def sign_digest(digest: bytes) -> bytes:
    # Your KMS/HSM returns (r, s, recovery_id)
    r: bytes = ...   # 32 bytes
    s: bytes = ...   # 32 bytes (must be low-s normalized)
    v: int   = ...   # 0 or 1

    return to_fuel_compact_signature(r, s, v)

The Fuel compact format stores the recovery ID in the MSB of the first byte of s:

s[0] = (recovery_id << 7) | (s[0] & 0x7F)

Warning

The s component must be low-s normalized before passing to to_fuel_compact_signature(). Most modern signing libraries (coincurve, ethers.js, etc.) do this automatically, but check your KMS documentation.

AWS KMS example

import boto3
from o2_sdk import ExternalSigner, to_fuel_compact_signature

kms = boto3.client("kms")

def aws_kms_sign(digest: bytes) -> bytes:
    response = kms.sign(
        KeyId="alias/my-trading-key",
        Message=digest,
        MessageType="DIGEST",
        SigningAlgorithm="ECDSA_SHA_256",
    )
    # Parse DER-encoded signature to (r, s)
    der_sig = response["Signature"]
    r, s, v = parse_der_signature(der_sig)  # your DER parser
    return to_fuel_compact_signature(r, s, v)

signer = ExternalSigner(
    b256_address="0x...",
    sign_digest=aws_kms_sign,
)

Custom Signer protocol

You can also implement the Signer protocol directly for full control. Use the shared digest helpers to ensure your framing matches the SDK:

from o2_sdk import fuel_personal_sign_digest

class MyCustomSigner:
    @property
    def b256_address(self) -> str:
        return "0x..."

    @property
    def address_bytes(self) -> bytes:
        return bytes.fromhex(self.b256_address[2:])

    def personal_sign(self, message: bytes) -> bytes:
        digest = fuel_personal_sign_digest(message)
        # Sign the 32-byte digest with your own signing backend
        return my_backend_sign(digest)

signer = MyCustomSigner()
session = await client.create_session(owner=signer, markets=["FUEL/USDC"])

For EVM accounts, use evm_personal_sign_digest() instead.