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 TypeScript SDK supports this via the
Signer interface, with built-in ExternalSigner and ExternalEvmSigner
classes.
The SDK handles all message framing (prefix bytes, hashing) internally. Your external signing function only needs to:
Use toFuelCompactSignature() to convert standard (r, s, recoveryId)
components to the expected format.
Signer InterfaceThe SDK defines a Signer interface that all wallet types implement.
Client methods (createSession, setupAccount, withdraw) accept any
Signer:
interface Signer {
readonly b256Address: string;
personalSign(message: Uint8Array): Uint8Array;
}
The built-in WalletState (returned by O2Client.generateWallet(),
O2Client.loadWallet(), etc.) extends Signer automatically.
For Fuel-native accounts, use ExternalSigner. The SDK handles the Fuel
personalSign message framing; your callback only signs a 32-byte digest:
import {
O2Client,
Network,
ExternalSigner,
} from "@o2exchange/sdk";
import { toFuelCompactSignature } from "@o2exchange/sdk/internals";
function myKmsSign(digest: Uint8Array): Uint8Array {
const { r, s, recoveryId } = myKms.sign("my-key-id", digest);
return toFuelCompactSignature(r, s, recoveryId);
}
const signer = new ExternalSigner("0x1234...abcd", myKmsSign);
const client = new O2Client({ network: Network.MAINNET });
await client.setupAccount(signer);
await client.createSession(signer, ["FUEL/USDC"]);
// Session actions use the session key — not the external signer
const response = await client.createOrder("FUEL/USDC", "buy", "0.02", "100");
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.
For EVM-compatible accounts (MetaMask, Ledger via Ethereum, etc.), use
ExternalEvmSigner. The only difference is the message framing: EVM uses
\x19Ethereum Signed Message:\n prefix + keccak256 instead of Fuel's
\x19Fuel Signed Message:\n prefix + SHA-256:
import { ExternalEvmSigner } from "@o2exchange/sdk";
const signer = new ExternalEvmSigner(
"0x000000000000000000000000abcd...1234", // b256 (zero-padded)
"0xabcd...1234", // EVM address
myKmsSign,
);
await client.setupAccount(signer);
await client.createSession(signer, ["FUEL/USDC"]);
The SignDigestFn callback must return a 64-byte Fuel compact signature.
Use toFuelCompactSignature to convert from standard (r, s, recoveryId)
components:
import { toFuelCompactSignature } from "@o2exchange/sdk/internals";
function signDigest(digest: Uint8Array): Uint8Array {
const r: Uint8Array = ...; // 32 bytes
const s: Uint8Array = ...; // 32 bytes (must be low-s normalized)
const v: number = ...; // 0 or 1
return toFuelCompactSignature(r, s, v);
}
The Fuel compact format stores the recovery ID in the MSB of the first
byte of s:
s[0] = (recoveryId << 7) | (s[0] & 0x7F)
Warning: The
scomponent must be low-s normalized before passing totoFuelCompactSignature. Most modern signing libraries (ethers.js, @noble/secp256k1, etc.) do this automatically, but check your KMS documentation.
import { KMSClient, SignCommand } from "@aws-sdk/client-kms";
import { ExternalSigner } from "@o2exchange/sdk";
import { toFuelCompactSignature } from "@o2exchange/sdk/internals";
const kms = new KMSClient({ region: "us-east-1" });
function awsKmsSign(digest: Uint8Array): Uint8Array {
const command = new SignCommand({
KeyId: "alias/my-trading-key",
Message: digest,
MessageType: "DIGEST",
SigningAlgorithm: "ECDSA_SHA_256",
});
const response = kms.send(command);
const { r, s, recoveryId } = parseDerSignature(response.Signature);
return toFuelCompactSignature(r, s, recoveryId);
}
const signer = new ExternalSigner("0x...", awsKmsSign);
You can also implement the Signer interface directly for full control.
Use the digest helpers to ensure your framing matches the SDK:
import {
type Signer,
} from "@o2exchange/sdk";
import { fuelPersonalSignDigest } from "@o2exchange/sdk/internals";
class MyCustomSigner implements Signer {
readonly b256Address: string;
constructor(address: string) {
this.b256Address = address;
}
personalSign(message: Uint8Array): Uint8Array {
const digest = fuelPersonalSignDigest(message);
return myBackendSign(digest);
}
}
const signer = new MyCustomSigner("0x1234...abcd");
await client.createSession(signer, ["FUEL/USDC"]);
For EVM accounts, use evmPersonalSignDigest instead of
fuelPersonalSignDigest.