WebSocket Authentication (FE → BE)
How the RISEx web client authenticates a user over the WebSocket so it can receive private channels (orders, positions, account, fills).
Reference: full client implementation
signWithSigner builds and signs the message; sendSocketAuth wraps it and sends the frame.
import { privateKeyToAccount } from 'viem/accounts';
const API_URL = process.env.API_URL ?? 'https://api.staging.rise.trade/api';
const WS_URL = process.env.WS_URL ?? 'wss://api.staging.rise.trade/ws/';
const SIGNING_KEY = process.env.SIGNING_KEY as `0x${string}` | undefined;
const ACCOUNT = process.env.ACCOUNT as `0x${string}` | undefined;
if (!SIGNING_KEY || !ACCOUNT) {
console.error('Missing env. Required: SIGNING_KEY (session private key), ACCOUNT (wallet address).');
process.exit(1);
}
const REGISTER_TYPES = {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
RegisterV2: [
{ name: 'signer', type: 'address' },
{ name: 'message', type: 'string' },
{ name: 'nonce', type: 'uint256' },
],
} as const;
interface Eip712DomainResponse {
data: { name: string; version: string; chain_id: number | string; verifying_contract: `0x${string}` };
}
interface NonceResponse {
data: { nonce: string };
}
const log = (step: string, payload?: unknown) => {
if (payload === undefined) console.log(`\n=== ${step} ===`);
else console.log(`\n=== ${step} ===\n`, JSON.stringify(payload, null, 2));
};
async function getJson<T>(path: string): Promise<T> {
const url = `${API_URL}${path}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
return (await res.json()) as T;
}
async function main() {
const signerAccount = privateKeyToAccount(SIGNING_KEY!);
log('Session key (signer) address', signerAccount.address);
log('Account (wallet) address', ACCOUNT);
// Step 1 — EIP-712 domain.
const domainRes = await getJson<Eip712DomainResponse>('/v1/auth/eip712-domain');
log('Step 1: GET /v1/auth/eip712-domain', domainRes);
const domain = {
name: domainRes.data.name,
version: domainRes.data.version,
chainId: BigInt(domainRes.data.chain_id),
verifyingContract: domainRes.data.verifying_contract,
};
// Step 2 — server nonce for the wallet account.
const nonceRes = await getJson<NonceResponse>(`/v1/auth/nonce?account=${ACCOUNT}`);
log('Step 2: GET /v1/auth/nonce', nonceRes);
const rawNonce = nonceRes.data.nonce;
const nonce = `0x${rawNonce.startsWith('0x') ? rawNonce.slice(2) : rawNonce}`;
// Step 3 — sign RegisterV2 with the session key.
const message = {
signer: signerAccount.address,
message: 'sign in with RISEx',
nonce: String(nonce),
};
const signature = await signerAccount.signTypedData({
domain,
types: REGISTER_TYPES,
primaryType: 'RegisterV2',
message,
});
log('Step 3: signed RegisterV2', { domain: { ...domain, chainId: domain.chainId.toString() }, message, signature });
// Step 4 — connect socket and send auth_v2 frame.
const authFrame = {
method: 'auth_v2',
params: {
account: ACCOUNT,
signer: signerAccount.address,
message: 'sign in with RISEx',
nonce,
expiration: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60,
signature,
},
};
const socket = new WebSocket(WS_URL);
socket.addEventListener('open', () => {
log('Step 4: socket OPEN, sending auth_v2', authFrame);
socket.send(JSON.stringify(authFrame));
});
socket.addEventListener('message', (event) => {
const text = typeof event.data === 'string' ? event.data : String(event.data);
let parsed: unknown = text;
try {
parsed = JSON.parse(text);
} catch {
/* keep raw text */
}
const method = (parsed as { method?: string })?.method;
if (method === 'auth' || method === 'auth_v2') {
const status = (parsed as { status?: string }).status;
log(`AUTH RESPONSE (${status === 'success' ? 'OK' : 'FAILED'})`, parsed);
if (status === 'success') {
// Prove the authenticated session works by subscribing to a private channel.
socket.send(JSON.stringify({ method: 'subscribe', params: { channel: 'account', makers: [ACCOUNT] } }));
log('Subscribed to private channel: account');
} else {
socket.close();
}
} else {
log('MESSAGE', parsed);
}
});
socket.addEventListener('error', (err) => {
console.error('\n=== SOCKET ERROR ===\n', err);
});
socket.addEventListener('close', (event) => {
log('SOCKET CLOSED', { code: event.code, reason: event.reason });
process.exit(0);
});
// Auto-exit after 15s so the script doesn't hang.
setTimeout(() => {
log('Timeout reached, closing.');
socket.close();
}, 15_000);
}
main().catch((err) => {
console.error('\n=== FATAL ===\n', err);
process.exit(1);
});
Overview
The client signs an EIP-712 RegisterV2 message with its session signing key (not the wallet key) and sends it over the open socket. The BE verifies the signature against the on-chain session key registered for the account, then flips the connection to authenticated.
FE BE
│ │
│ GET /v1/auth/eip712-domain ───────────► │ (1) fetch signing domain
│ ◄───────────────────── domain │
│ │
│ GET /v1/auth/nonce?account=0x.. ───────► │ (2) fetch server nonce
│ ◄───────────────────── { nonce } │
│ │
│ sign RegisterV2 with session key (3) │
│ │
│ ws.send({ method: "auth_v2", params }) ► │ (4) verify signature + nonce
│ ◄──── { method: "auth_v2", status } │ against on-chain session key
│ │
Step 1 — EIP-712 domain
GET /v1/auth/eip712-domain
{
"data": {
"name": "RISEx Auth",
"version": "1",
"chain_id": 11155931,
"verifying_contract": "0x..."
}
}The client converts this into the EIP-712 domain used for signing:
{
name,
version,
chainId: BigInt(chain_id),
verifyingContract: verifying_contract,
}Step 2 — Server nonce
GET /v1/auth/nonce?account=0x<wallet address>
{ "data": { "nonce": "0x..." } }The nonce must come from the BE (same source as JWT login). The client does not generate a local nonce: the auth contract rejects reused/skewed nonces, so a server-authoritative value avoids clock-skew failures. The client normalizes the hex (strips/re-adds the 0x prefix) and signs the value as a string.
Step 3 — EIP-712 message to sign
The session signing key signs this typed data:
- primaryType:
RegisterV2 - types:
RegisterV2: [
{ name: 'signer', type: 'address' },
{ name: 'message', type: 'string' },
{ name: 'nonce', type: 'uint256' },
]- message:
{
signer: '0x<session signing key address>',
message: 'sign in with RISEx',
nonce: '0x...', // value from step 2, stringified
}The
signerfield is the session key address derived from the signing key, not the user's wallet. The signature is produced by that session key.
Step 4 — WebSocket auth message
The client sends this frame over the open socket:
{
"method": "auth_v2",
"params": {
"account": "0x<wallet address>", // the user's main account
"signer": "0x<session key address>", // session key that signed
"message": "sign in with RISEx",
"nonce": "0x...", // server nonce from step 2
"expiration": 1790000000, // unix seconds (now + 1 year)
"signature": "0x..." // EIP-712 signature from step 3
}
}Payload type (IAuthSocketData):
interface IAuthSocketData {
account: `0x${string}`;
signer: `0x${string}`;
message: string;
nonce: string;
expiration: number; // unix seconds
signature: string;
}BE response
The BE replies on the same socket with:
{ "method": "auth_v2", "status": "success" } // authenticated
{ "method": "auth_v2", "status": "error" } // rejectedOn success the client sets authSocket = true and may subscribe to private channels. On any non-success status the client treats it as a failure and runs its retry/recovery path (see below). The same handler also accepts method: "auth" for the JWT flow.
Client failure handling
On a failed auth_v2 response the client:
- Re-checks
getSessionKeyStatus(account, signer)on-chain. If the session key is no longer active, it resets auth state and reports the failure — no retry. - Otherwise retries the full sign + send up to 3 times, 1s apart, fetching a fresh nonce each attempt.
This means the BE may receive several auth_v2 frames in close succession after a rejection; each carries a freshly fetched nonce.