JavaScript/TypeScript


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 signer field 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" }     // rejected

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

  1. 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.
  2. 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.