BSL Session Keys
The repository TypeScript example supports the SENTICORE_APP_KEY HTTP header
and Direct-TCP Logon extension. When supplied, the gateway must acknowledge
app-key channel binding; the client refuses a silent downgrade. The example
checks explicit spot price/quantity against exchange-info, distinguishes the
COMMAND ACK from the execution outcome, and waits for the matching terminal
report (including a fully filled V2 lifecycle report). It never automatically
resubmits an uncertain order.
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.
For the Dead Man Switch, allowedActions must
include cancel as well as the desired placement actions. This permission is
required for all /api/v1/bsl/dms controls, including status and heartbeats;
otherwise Core returns DMS_AUTH_REJECTED. cancelOnDisconnect: false does not
disable an explicitly armed DMS epoch. Coordinate account nonces between
processes and verify a successful order execution before testing expiry.
Venue rollout order
Current state: session-key Direct TCP is live on production (phase 5 below).
On September 13 an external market maker registered its own Ed25519 key and
traded through AuthSidecarV2; that client measured CoreAck p50 30–38 ms from
Vienna (network RTT 29.5 ms), which is one measurement, not a guarantee.
Wallet-signed AuthSidecarV1 frames are still acknowledged 202 but not
applied. Registration success is not a Direct TCP order or terminal-delivery
proof: run the minimum-notional reject, valid-order-after-reject, cancel and
replay gates on your own account first.
Do not migrate a market maker from wallet-signed Direct TCP to session-key Direct TCP in one step. The following is a rollout plan, not evidence that every phase is enabled in the current deployment. Read the connectivity contract and prove an authenticated order acknowledgement and terminal report before switching production quoting:
| 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. Optional future session-key-only policy | Requires a separately verified account-level enforcement mechanism; this table does not establish that one is deployed. | Stop using fallback only after venue confirmation and conformance. | Deployment-specific policy, not a current API guarantee. |
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.
App identity, scopes and account discovery
Do not confuse the following credentials:
| Purpose | Authorization |
|---|---|
| Account discovery and WS read token | Institutional HMAC credential with read scope, bound to the requested account |
| Register/list/revoke a session key | Owner-wallet-signed wallet-admin request; registration also includes the EIP-712 delegation |
| Attribute a broker request | Registered app identity, and an app key when required by the venue; not the institutional HMAC secret |
| Submit a Direct TCP session-key order | Registered Ed25519 key, policy, account/app binding and monotone sequences |
Discover the 32-byte trading-account ID using
GET /api/v1/trading/accounts?owner=<owner_wallet> with Read authorization.
/api/v1/trading-accounts is not that route. Order payloads use the 20-byte
engine account; delegation accountIdHex uses the 32-byte trading-account ID.
Fetch the connectivity bundle with account-bound HMAC to receive account routing
context. An anonymous 200 response only proves public discovery, not account access.
For the Sentico first-party app, use appName: "Sentico" and
domain: "app.sentico-labs.xyz". The registry resolves a signed app name by
app id (sentico) or display name (Sentico); "Sentico" is the value verified
on production. sentipredict (the Sentirlo broker) is a different registered
app and gets 401 here; it is not a universal signing alias. Brokers must use
their own registered app ID and matching domain. The wallet-admin signature,
delegation and order attribution must agree; never relabel an already signed
payload.
- Send
Origin: https://app.sentico-labs.xyzonPOST /api/v1/bsl/session-keys/register; the registry binds that origin to the Sentico app. A browser sends its actual Origin; a server-side client must set it explicitly. - Send the request without institutional machine-auth headers (
SC-*); with them the route answers403. The wallet-admin signature in the body is the authorization. - Adding an Origin does not fix a mismatched signed app/domain. The
cryptographic EIP-712 domain name
SentiCorebelow is separate from the registered application ID.
Policy shape
{
"allowedMarkets": [7],
"allowedActions": ["spot_place", "cancel"],
"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
The executable example requires explicit SENTICORE_PRICE_MICRO and
SENTICORE_QTY_LOTS. It validates a currently tradable spot market against
GET /api/v1/public/exchange-info: tick, lot, minimum notional and maximum
quantity. There is deliberately no universal qty=1 default and no silent
upsizing of a funded order. The example opts into private ExecutionReports and
does not report success merely because a CoreAck arrived.
For the Sentico first-party example, sign with appName: "Sentico" (the
registered app id sentico also resolves). Other apps must use their own
registered ID and domain. The server-side example sends
Origin: https://app.sentico-labs.xyz and no SC-* machine-auth headers on the
register call; an app key is a separate channel-binding credential, not an
institutional HMAC secret. Do not add an arbitrary Origin to impersonate
another application. Omitting Origin on wallet-control calls is rejected.
An existing session key requires an explicitly persisted next
SENTICORE_SESSION_SEQ; reconnect must not reset it to 1. The strategy's
runner lifetime does not extend the key's expiry or provide a Dead-Man-Switch.
This is an integration sketch, not a zero-configuration live trading command.
See sdks/typescript/examples/bsl-tcp-session-key.ts for the complete example;
it requires explicit live-order opt-in. Supply your institutional Read credentials
for connectivity and use separate engineAccount (20 bytes) and
tradingAccountId (32 bytes). Reused session keys require a persisted sequence
counter, not a fresh 1 on each process restart.
import {
SenticoreClient,
bslCompactRoutingFromConnectivityBundle,
createSessionKey,
encodeBslSessionOrder,
sessionDelegationTypedData,
sessionKeyPolicyHashHex,
signSessionDelegationEip712,
SESSION_DELEGATION_SCHEME_EIP712_V2,
type LocalActionPayload,
type SessionKeyPolicy
} from "@sentico-labs/sdk";
// Production host. For the sandbox, substitute api.sentico-sandbox.xyz
// in all three and use sandbox credentials: see /integration/sandbox/overview
const client = new SenticoreClient({
orderEntryHttpBaseUrl: "https://api.sentico-labs.xyz",
tradingHttpBaseUrl: "https://api.sentico-labs.xyz",
publicHttpBaseUrl: "https://api.sentico-labs.xyz",
machineAuth: { apiKeyId, apiSecret, apiPassphrase }
});
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: [marketId],
allowedActions: ["spot_place", "cancel"],
maxOrderQtyLots: 1_000_000,
maxNotionalMicro: 250_000_000,
gatewayId: routing.gatewayId,
cancelOnDisconnect: false
};
const walletAuth = {
// Sandbox: "app.sentico-sandbox.xyz"
domain: "app.sentico-labs.xyz",
appName: "Sentico",
scheme: "senticore_typed_v2",
// Sandbox: "sandbox". A label only - the server enforces chainId, not this.
environment: "production",
// 42161 in production, 421614 in the sandbox. Never hardcode it: it comes
// from the connectivity bundle of whichever host you connected to.
chainId: routing.chainBinding.chainId,
walletAddress,
nonce: walletAdminNonce,
issuedAt: now,
expiresAt: now + 5 * 60_000,
signature: walletAdminSignature
};
const delegation = {
walletAuth,
chainBinding: routing.chainBinding,
accountIdHex: tradingAccountId,
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);
// Send with Origin https://app.sentico-labs.xyz and without SC-* machine-auth headers.
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: engineAccount,
appId: "sentico",
nonce,
ts: Date.now(),
action: {
kind: "SpotPlaceOrder",
market: marketId,
side: "Bid",
price: priceMicro,
qty: qtyLots,
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.
Session-key sidecar encodings
encodeBslSessionOrder has two paths. Do not infer wire format from the name V2:
| Order shape | Sidecar encoding | SignedAction payload |
|---|---|---|
| One independent place/cancel/amend frame | Fixed binary session-key wire v1, 236 bytes | Omitted; the Core reconstructs receipt metadata from the authenticated frame |
| Atomic quote-replace group | JSON session-key sidecars inside the binary CompactFrameGroup | Required for the group's canonical SignedAction |
The fixed sidecar contains account, key, sequence, policy hash, order hash, signature and binding. Both paths enforce the registered policy and chain-bound signature. A gateway/session handshake version is distinct from this sidecar version.
JSON compatibility / atomic-group form
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": [canonical SignedAction JSON bytes]
}
payload_bytes is mandatory in this JSON/atomic-group form, not in the fixed
independent-frame sidecar. In this form the SDK serializes
the canonical SignedAction as UTF-8 JSON bytes (serde_json compatible), with
the Ed25519 signature in the embedded signature object. The verifier resolves
the registered session key from session_key_id, checks the policy hash and
session sequence, re-decodes the payload, recomputes order_hash, verifies the
Ed25519 signature, and only 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=<explicitly-approved-engine-account>
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 only after explicit general-access approval and completed reject/retry/terminal/recovery conformance. Do not clear it as a troubleshooting step: empty means broad access, not disabled access.
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
Build a fresh wallet-admin authorization signed for revoke_session_key
and this sessionKeyId; do not reuse the registration signature or its nonce.
await client.orderEntry.revokeSessionKey({
walletAuth: freshRevokeWalletAuth,
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.