Titandocs

Authentication

Session keys, canonical bytes, and the three headers the BFF must forward without touching.

The BFF holds no private key. Every trading call carries a signature produced in the browser.

The flow

The wallet holds the party key

The user's Canton wallet owns the party key. It will sign exactly one thing.

Derive a session keypair

At session start the browser generates an Ed25519 keypair. The private half never leaves the tab.

Sign the canonical Session-Auth blob

The wallet signs a CIP-0103 canonical message binding the session public key to the party.

POST /v1/session-auth
Content-Type: application/json

{
  "party": "Alice::1220…",
  "sessionPublicKey": "…",
  "expiresAt": "2026-08-03T18:00:00Z",
  "walletSignature": "…"
}

Response:

{ "sessionId": "sess_7f2c…", "expiresAt": "2026-08-03T18:00:00Z" }

Sign every subsequent call with the session key

Orders, cancels, leverage changes, and user/me are signed with the session key — the wallet is never prompted again.

Send three headers

X-User:        Alice::1220…
X-Session-Id:  sess_7f2c…
X-Session-Sig: 3a91f0…        # hex(ed25519 sig over the canonical bytes)

The BFF forwards all three verbatim. It does not parse, re-sign, or validate them.

Canonical bytes

lib/auth/canonical.ts in the interface builds the signed payload. It is not JSON.stringify — field order and encodings are fixed, because the engine reconstructs the same bytes to verify.

// four canonical builders, one per operation
canonicalSessionAuth({ party, sessionPublicKey, expiresAt });
canonicalOrder({ market, side, size, price, nonce, reduceOnly, … });
canonicalCancel({ orderId, nonce });
canonicalLeverage({ market, leverage, nonce });

A single byte of drift between the canonical blob and the JSON body means the engine rejects. This is a feature — it is what makes verbatim forwarding safe — but it also means the canonical builders and the request bodies must be changed together, in one commit.

Fingerprint binding

A party id is Hint::Fingerprint, where the fingerprint derives from the party's public key. verifyPartyKey recomputes it from the presented key and compares — that is what stops one party from claiming another's identity.

Nonces

Every signed operation carries a nonce. The engine rejects a reused nonce even for an identical payload, which is what prevents replay of a captured request.

Increment the nonce before a retry, not after the response. A network timeout on a request the engine actually processed leaves you unsure whether the nonce was consumed — treating it as consumed is the safe assumption.

Session expiry

Sessions carry expiresAt. The interface persists the session record (lib/auth/session-storage.ts, expiry-checked) so a page reload keeps trading alive.

If the user refuses the wallet signature, the connect still succeeds — they are logged in but cannot trade. The order form surfaces "Enable trading", which re-runs session-auth.

Signature encoding

VITE_SIGN_MESSAGE_ENCODING selects how the CIP-0103 wallet expects the payload:

ValueWallets
base64 (default)Most extension wallets — they atob() the payload
rawThe Splice reference signer — signs the literal text

5N Loop ignores this setting entirely. Its RPC is SIGN_RAW_MESSAGE, so the blob always goes over as literal text whatever the variable says. Getting the encoding wrong elsewhere produces a well-formed signature over the wrong bytes — the engine rejects with no useful message.

Auth gate

Routes under /v1/users/:party/... are wrapped by app.authenticate and app.requireOwnParty. Both are wired but disabled by default (AUTH_DISABLED=true), so party-scoped reads are currently open. The faucet route relies on requireOwnParty — a party can only top up itself.

On this page