Skip to main content

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:

  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.

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:

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. Optional future session-key-only policyRequires 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:

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.

App identity, scopes and account discovery​

Do not confuse the following credentials:

PurposeAuthorization
Account discovery and WS read tokenInstitutional HMAC credential with read scope, bound to the requested account
Register/list/revoke a session keyOwner-wallet-signed wallet-admin request; registration also includes the EIP-712 delegation
Attribute a broker requestRegistered app identity, and an app key when required by the venue; not the institutional HMAC secret
Submit a Direct TCP session-key orderRegistered 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.

Registration request rules (verified on production, September 13)
  • Send Origin: https://app.sentico-labs.xyz on POST /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 answers 403. 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 SentiCore below 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:

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​

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 shapeSidecar encodingSignedAction payload
One independent place/cancel/amend frameFixed binary session-key wire v1, 236 bytesOmitted; the Core reconstructs receipt metadata from the authenticated frame
Atomic quote-replace groupJSON session-key sidecars inside the binary CompactFrameGroupRequired 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_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=<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:

  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 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:

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​

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.