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 :class:`~o2_sdk.crypto.ExternalSigner` and :class:`~o2_sdk.crypto.ExternalEvmSigner` classes. How it works ------------ The SDK handles all message framing (prefix bytes, hashing) internally via the shared helpers :func:`~o2_sdk.crypto.fuel_personal_sign_digest` (Fuel) and :func:`~o2_sdk.crypto.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 :func:`~o2_sdk.crypto.to_fuel_compact_signature` to convert from standard ``(r, s, recovery_id)`` components. Fuel-native external signer ---------------------------- For Fuel-native accounts: .. code-block:: python 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.): .. code-block:: python 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: :class:`ExternalEvmSigner` uses Ethereum's ``\x19Ethereum Signed Message:\n`` prefix + keccak-256, while :class:`ExternalSigner` uses Fuel's ``\x19Fuel Signed Message:\n`` prefix + SHA-256. Implementing the callback -------------------------- The :data:`~o2_sdk.crypto.SignDigestFn` callback must return a **64-byte Fuel compact signature**. Here is how to build one from standard components: .. code-block:: python 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``: .. code-block:: text s[0] = (recovery_id << 7) | (s[0] & 0x7F) .. warning:: The ``s`` component **must be low-s normalized** before passing to :func:`~o2_sdk.crypto.to_fuel_compact_signature`. Most modern signing libraries (coincurve, ethers.js, etc.) do this automatically, but check your KMS documentation. AWS KMS example ---------------- .. code-block:: python 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 :class:`~o2_sdk.crypto.Signer` protocol directly for full control. Use the shared digest helpers to ensure your framing matches the SDK: .. code-block:: python 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 :func:`~o2_sdk.crypto.evm_personal_sign_digest` instead.