Skip to main content

BSL Session Keys

Session keys move the Direct TCP quote loop away from per-order wallet ECDSA signing. The owner wallet authorizes a short-lived Ed25519 key once, then the quote engine signs each compact frame locally with that session key.

This is the recommended order of operations for market makers that need the lowest BSL Direct TCP latency:

  1. Read /api/v1/bsl/connectivity.
  2. Create an Ed25519 session key locally.
  3. Build a tight policy for one account, one gateway, and the intended markets.
  4. Have the owner wallet sign the EIP-712 session delegation.
  5. Register the session key through the wallet-authorized control plane.
  6. Open a persistent BSL Direct TCP session.
  7. Encode each quote with encodeBslSessionOrder.
  8. Revoke the session key when the quote engine stops or rotates.

Venue rollout order

Do not migrate a market maker from wallet-signed Direct TCP to session-key Direct TCP in one step. The venue rollout is:

PhaseServer behaviorClient behaviorPurpose
0. BaselineBSL Direct TCP accepts wallet-signed AuthSidecarV1.Keep current persistent TCP flow.Preserve the known-good fallback and benchmark p50/p99.
1. SpecNo order-entry behavior change.Generate local Ed25519 keys and chain-bound EIP-712 delegations.Fix the delegation, policy, expiry, and revocation semantics.
2. Control planeSession keys can be registered, listed, and revoked.Register short-lived keys, but still trade with wallet-signed orders.Prove key lifecycle and audit.
3. ShadowWallet-signed orders still apply. Optional shadow fields are verified and logged only.Send wallet-signed AuthSidecarV1 with sessionKeyShadow.Prove policy, market routing, chain binding, and replay checks without changing acceptance.
4. Optional canary V2AuthSidecarV2 can be limited to allowlisted accounts by setting BSL_SESSION_KEY_CANARY_ACCOUNTS.One internal account sends small session-key orders.Prove the real hot path with limited blast radius before general access.
5. Session-key preferredSession-key Direct TCP is enabled for all registered session-key accounts when BSL_SESSION_KEY_CANARY_ACCOUNTS is empty. ECDSA remains fallback.Market maker uses encodeBslSessionOrder.Remove wallet signing from the quote loop.
6. Session-key onlyECDSA fallback is disabled per account after conformance.Hotpath uses only session-key frames.Production steady state.

Why it exists

The older hot path signs a canonical SignedAction with secp256k1 and carries that signed action inside AuthSidecar.payload_bytes. It is secure, but wallet ECDSA signing can dominate the local hot path.

Session keys keep the same server-side account authorization model while changing the per-order signature:

ItemWallet-signed Direct TCPSession-key Direct TCP
SetupNone beyond existing account signerWallet signs one delegation
Per-order signersecp256k1 account signerEd25519 session key
Sidecar payloadcold SignedAction bytespolicy hash, order hash, seq, signature
Replay guardaction nonce plus gateway sequenceaction nonce, gateway sequence, session sequence
Revocationrevoke account signer/agentrevoke one session key

The server still verifies account ownership, expiry, policy hash, allowed markets, allowed actions, order size, notional limit, gateway id, signature, and per-session sequence monotonicity.

Control-plane endpoints

Session-key management is wallet-authorized. These are JSON HTTP routes, not TCP messages:

POST /api/v1/bsl/session-keys/register
POST /api/v1/bsl/session-keys/list
POST /api/v1/bsl/session-keys/revoke

The same wallet admin auth model used for API agents is used here. The register_session_key wallet-admin action proves that the owner wallet permits creation of the session key for the account.

Policy shape

{
"allowedMarkets": [7],
"allowedActions": ["spot_place"],
"maxOrderQtyLots": 1000000,
"maxNotionalMicro": 250000000,
"maxOpenExposureMicro": null,
"gatewayId": 1,
"sourceIpAllowlist": [],
"clientCertFingerprint": null,
"cancelOnDisconnect": false
}

Policy rules in the current beta:

FieldStatus
allowedMarketsEnforced against real market ids. Empty means all markets available to the account. For cancel and amend, the hot path also proves the referenced live order's actual market before accepting.
allowedActionsEnforced per compact frame leg. Supported values: place, spot_place, cancel, amend, quote_replace, spot_quote_replace. Atomic groups are not automatically promoted to quote_replace; each generated cancel/place leg must be allowed by its own action.
maxOrderQtyLotsEnforced per generated compact frame.
maxNotionalMicroEnforced per generated place frame.
gatewayIdEnforced when set.
cancelOnDisconnectSupported for session-key Direct TCP. The gateway emits a bounded disconnect notice, and the sequencer sweeps only order ids tracked for that session key. Place frames under this policy must carry a valid derived order id so the sweep can be exact.
cancel / amend with allowedMarketsSupported for live known orders. The compact frame must carry the referenced orderId, the order-id hash must match, the order must belong to the compact account, and the live order market must equal the routed market and an allowed market. Unknown, stale, cross-account, or mismatched-market cancels are rejected before persist.
maxOpenExposureMicroRejected at registration until exposure-cache enforcement is live.
sourceIpAllowlistRejected at registration until gateway-side source-IP enforcement is live.
clientCertFingerprintRejected at registration until mTLS fingerprint enforcement is live.

Rejecting unsupported policy fields is intentional. A policy must not appear to protect risk limits that the hot path cannot yet enforce.

For standalone Direct TCP Cancel and AmendOrder, clients still provide routingMarket because the signed action only carries order_id. With session-key auth, that value is routing context only: the sequencer derives the order id from the compact frame, checks the compact order-id hash, looks up the live order, and then enforces account and market equality against the session policy. If the order is no longer live, resnapshot through drop-copy/gap-fill or order reads before retrying.

When cancelOnDisconnect=true, do not rely on a broad account-level panic cancel. The session key tracks only orders admitted through that key. On TCP teardown the gateway sends SessionDisconnect to the sequencer, the sequencer rechecks the registered policy and compact account binding, then enqueues cancels for still-live tracked orders. Filled, already-canceled, stale, or cross-account order ids are ignored or untracked.

TypeScript flow

import {
SenticoreClient,
bslCompactRoutingFromConnectivityBundle,
createSessionKey,
encodeBslSessionOrder,
sessionDelegationTypedData,
sessionKeyPolicyHashHex,
signSessionDelegationEip712,
SESSION_DELEGATION_SCHEME_EIP712_V2,
type LocalActionPayload,
type SessionKeyPolicy
} from "@sentico-labs/sdk";

const client = new SenticoreClient({
orderEntryHttpBaseUrl: "https://api.sentico-labs.xyz",
tradingHttpBaseUrl: "https://api.sentico-labs.xyz",
publicHttpBaseUrl: "https://api.sentico-labs.xyz"
});

const bundle = await client.orderEntry.getConnectivityBundle();
const routing = bslCompactRoutingFromConnectivityBundle(bundle.data);

if (routing.accountIdx === undefined) {
throw new Error("account has no compactAccountIdx yet");
}

const sessionKey = createSessionKey();
const now = Date.now();
const policy: SessionKeyPolicy = {
allowedMarkets: [7],
allowedActions: ["spot_place"],
maxOrderQtyLots: 1_000_000,
maxNotionalMicro: 250_000_000,
gatewayId: routing.gatewayId,
cancelOnDisconnect: false
};

const walletAuth = {
domain: "sentico-labs.xyz",
appName: "SentiCore",
scheme: "senticore_typed_v2",
environment: "production",
chainId: routing.chainBinding.chainId,
walletAddress,
nonce: walletAdminNonce,
issuedAt: now,
expiresAt: now + 5 * 60_000,
signature: walletAdminSignature
};

const delegation = {
walletAuth,
chainBinding: routing.chainBinding,
accountIdHex: account,
sessionPublicKey: sessionKey.publicKey,
policy,
validFrom: now - 1_000,
expiresAt: now + 60 * 60_000,
delegationNonce,
revocationEpoch: 0
};

const typedDelegation = sessionDelegationTypedData(delegation);
const delegationSignature =
walletClient
? await walletClient.signTypedData(typedDelegation)
: signSessionDelegationEip712(delegation, ownerPrivateKey);

const registered = await client.orderEntry.registerSessionKey({
...delegation,
algorithm: "ed25519",
delegationScheme: SESSION_DELEGATION_SCHEME_EIP712_V2,
delegationSignature
});

const policyHash =
(registered.data as any).policyHash ?? sessionKeyPolicyHashHex(policy);

let gatewaySeq = 1n;
let sessionSeq = 1n;
const tcpSessionId = BigInt(Date.now()) * 1_000_000n;

const payload: LocalActionPayload = {
account,
nonce,
ts: Date.now(),
action: {
kind: "SpotQuoteReplace",
market: 7,
legs: [
{
side: "Bid",
price: 998400,
qty: 1000,
timeInForce: "post_only"
}
]
}
};

const encoded = encodeBslSessionOrder(payload, {
...routing,
accountIdx: routing.accountIdx,
sessionId: tcpSessionId,
gatewaySeq,
sessionPrivateKey: sessionKey.privateKey,
sessionKeyId: sessionKey.sessionKeyId,
sessionSeq,
policyHash
});

for (const message of encoded.tcpMessages) {
socket.write(message);
}

gatewaySeq = encoded.nextGatewaySeq;
sessionSeq += BigInt(encoded.frameBytes.length);

In production, keep the BSL TCP socket open and reuse the cached connectivity bundle until the gateway rejects a stale compact mapping version or the bundle TTL in your strategy expires.

During Phase 3 shadow rollout, keep the legacy wallet-signed order as the apply authority and attach optional session-key shadow material:

const signed = signAction(payload, walletPrivateKey, {
chainBinding: routing.chainBinding
});

const encoded = encodeBslOrderFromSignedAction(signed, {
...routing,
accountIdx: routing.accountIdx,
sessionId: tcpSessionId,
gatewaySeq,
chainBinding: routing.chainBinding,
sessionKeyShadow: {
sessionPrivateKey: sessionKey.privateKey,
sessionKeyId: sessionKey.sessionKeyId,
sessionSeq,
policyHash
}
});

The order is still accepted or rejected by the wallet-signed SignedAction. The shadow fields are checked only when BSL_SESSION_KEY_AUTH_SHADOW_ENABLED=true; failures are logged and do not update the registered session key's last_seq.

Production session-key registration uses senticore_session_delegation_eip712_v2. The EIP-712 domain is:

name: SentiCore
version: 1
chainId: connectivity.actionSigning.chainId
verifyingContract: connectivity.actionSigning.verifyingContract

The signed primary type is SessionDelegationV2. The legacy buildSessionDelegationMessage/signSessionDelegation helpers remain only for local migration flows; guarded runtimes reject legacy session delegations.

AuthSidecarV2

encodeBslSessionOrder builds AuthSidecarV2 for each generated compact frame:

{
"auth_scheme": "senticore_session_key_v1",
"algorithm": "ed25519",
"signer_id": [32 byte session key id],
"session_key_id": [32 byte session key id],
"session_seq": 1,
"policy_hash": [32 bytes],
"order_hash": [32 bytes],
"signature": [64 byte Ed25519 signature],
"auth_binding": [32 bytes],
"payload_bytes": null
}

payload_bytes is intentionally null. The verifier resolves the registered session key from session_key_id, checks the policy hash and session sequence, recomputes order_hash, verifies the Ed25519 signature, and then applies the compact frame. The signed order_hash preimage is bound to:

  • chain id and verifying contract
  • registered policy_hash
  • session_key_id
  • session_seq
  • the compact frame with auth_binding zeroed

Because session_seq and session_key_id are inside the signed preimage, a captured sidecar cannot be retagged to a new sequence or another session key.

The compact frame keeps its existing payload_hash/order-id semantics. Session auth adds a separate order_hash; it does not overwrite the order id.

Runtime flags

Session-key Direct TCP is feature-flagged. The feature flag opens the verifier; the optional canary list narrows it to selected accounts:

BSL_SESSION_KEY_AUTH_SHADOW_ENABLED=false
BSL_SESSION_KEY_AUTH_ENABLED=true
BSL_SESSION_KEY_CANARY_ACCOUNTS=

When BSL_SESSION_KEY_CANARY_ACCOUNTS is empty or unset, every account with an active registered session key can use AuthSidecarV2, subject to its policy, expiry, revocation epoch, account binding, chain binding, market/action limits, monotone session_seq, and gateway sequence checks. Setting BSL_SESSION_KEY_CANARY_ACCOUNTS=0xabc...,0xdef... is an operations brake for a limited rollout; it is not required for normal production use.

Production cutover order:

  1. Deploy backend with BSL_SESSION_KEY_AUTH_ENABLED=false.
  2. Register a session key on a lab account.
  3. Enable BSL_SESSION_KEY_AUTH_SHADOW_ENABLED=true and send wallet-signed orders that include sessionKeyShadow.
  4. Watch shadow logs for policy, order-hash, replay, market, and signature mismatches. Shadow checks do not mutate last_seq and do not change whether the wallet-signed order is accepted.
  5. Enable BSL_SESSION_KEY_AUTH_ENABLED=true. During first rollout, set BSL_SESSION_KEY_CANARY_ACCOUNTS to the lab account; for general access, leave it empty.
  6. Run Direct TCP conformance: place, quote-replace, reject cases, reconnect, replay, stale mapping, and revocation.
  7. Enable cancelOnDisconnect=true for the session key that needs it and prove that a TCP teardown cancels only orders admitted through that key.
  8. Remove the canary list after operational monitoring is clean so the lane is open to all registered session-key accounts.

The runtime auth metrics expose shadow readiness separately from real auth:

FieldMeaning
externalGatewayAuth.sessionKeyShadowSuccessTotalWallet-signed order also passed the optional session-key shadow verifier.
externalGatewayAuth.sessionKeyShadowRejectTotalWallet-signed order applied by primary auth, but its shadow verifier failed.
externalGatewayAuth.sessionKeyShadowRejects[]Reject reasons such as policy hash mismatch, replay, unknown key, stale market mapping, or signature failure.

Do not promote an account from shadow to real AuthSidecarV2 until sessionKeyShadowRejectTotal stays flat through reconnect, replay, quote-replace, place, stale mapping, and revocation drills.

Revocation and rotation

await client.orderEntry.revokeSessionKey({
walletAuth,
sessionKeyId: sessionKey.sessionKeyId,
reason: "strategy shutdown"
});

Use short lifetimes. The current maximum lifetime is 24 hours. A normal quote engine should rotate session keys on process restart, strategy handoff, or key store changes.