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:
- Read
/api/v1/bsl/connectivity. - Create an Ed25519 session key locally.
- Build a tight policy for one account, one gateway, and the intended markets.
- Have the owner wallet sign the EIP-712 session delegation.
- Register the session key through the wallet-authorized control plane.
- Open a persistent BSL Direct TCP session.
- Encode each quote with
encodeBslSessionOrder. - 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:
| Phase | Server behavior | Client behavior | Purpose |
|---|---|---|---|
| 0. Baseline | BSL Direct TCP accepts wallet-signed AuthSidecarV1. | Keep current persistent TCP flow. | Preserve the known-good fallback and benchmark p50/p99. |
| 1. Spec | No order-entry behavior change. | Generate local Ed25519 keys and chain-bound EIP-712 delegations. | Fix the delegation, policy, expiry, and revocation semantics. |
| 2. Control plane | Session keys can be registered, listed, and revoked. | Register short-lived keys, but still trade with wallet-signed orders. | Prove key lifecycle and audit. |
| 3. Shadow | Wallet-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 V2 | AuthSidecarV2 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 preferred | Session-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 only | ECDSA 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:
| Item | Wallet-signed Direct TCP | Session-key Direct TCP |
|---|---|---|
| Setup | None beyond existing account signer | Wallet signs one delegation |
| Per-order signer | secp256k1 account signer | Ed25519 session key |
| Sidecar payload | cold SignedAction bytes | policy hash, order hash, seq, signature |
| Replay guard | action nonce plus gateway sequence | action nonce, gateway sequence, session sequence |
| Revocation | revoke account signer/agent | revoke 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:
| Field | Status |
|---|---|
allowedMarkets | Enforced 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. |
allowedActions | Enforced 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. |
maxOrderQtyLots | Enforced per generated compact frame. |
maxNotionalMicro | Enforced per generated place frame. |
gatewayId | Enforced when set. |
cancelOnDisconnect | Supported 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 allowedMarkets | Supported 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. |
maxOpenExposureMicro | Rejected at registration until exposure-cache enforcement is live. |
sourceIpAllowlist | Rejected at registration until gateway-side source-IP enforcement is live. |
clientCertFingerprint | Rejected 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_idsession_seq- the compact frame with
auth_bindingzeroed
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:
- Deploy backend with
BSL_SESSION_KEY_AUTH_ENABLED=false. - Register a session key on a lab account.
- Enable
BSL_SESSION_KEY_AUTH_SHADOW_ENABLED=trueand send wallet-signed orders that includesessionKeyShadow. - Watch shadow logs for policy, order-hash, replay, market, and signature
mismatches. Shadow checks do not mutate
last_seqand do not change whether the wallet-signed order is accepted. - Enable
BSL_SESSION_KEY_AUTH_ENABLED=true. During first rollout, setBSL_SESSION_KEY_CANARY_ACCOUNTSto the lab account; for general access, leave it empty. - Run Direct TCP conformance: place, quote-replace, reject cases, reconnect, replay, stale mapping, and revocation.
- Enable
cancelOnDisconnect=truefor the session key that needs it and prove that a TCP teardown cancels only orders admitted through that key. - 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:
| Field | Meaning |
|---|---|
externalGatewayAuth.sessionKeyShadowSuccessTotal | Wallet-signed order also passed the optional session-key shadow verifier. |
externalGatewayAuth.sessionKeyShadowRejectTotal | Wallet-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.