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:
| Value | Wallets |
|---|---|
base64 (default) | Most extension wallets — they atob() the payload |
raw | The 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.