VeztaVezta
Architecture

Auth System

Wallet-based authentication, Google login, Telegram account linking, and JWT lifecycle

Vezta supports two sign-in methods that produce separate accounts:

  • Wallet-based -- Solana (tweetnacl) or EVM (ethers) signature verification
  • Google OAuth -- backend creates a synthetic Solana wallet for the user

In both cases, the backend issues JWT tokens for subsequent API access. Telegram is a linkable channel on top of an existing account (see below), not a separate sign-in.

Wallet Authentication Flow

Step-by-Step

1. Request Nonce

GET /api/v1/auth/wallet/nonce?address=...&chain=solana|evm

Backend generates a random nonce string and returns it. Rate-limited to 20 req/min per IP.

2. Sign Message

The client uses the connected wallet to sign the nonce. No network call -- pure cryptographic operation.

3. Verify Signature

POST /api/v1/auth/wallet
{
  "address": "...",
  "chain": "solana" | "evm",
  "signature": "...",
  "nonce": "...",
  "referralCode": "..."  // optional, first-time only
}

Rate-limited to 10 req/min per IP. Verification per chain:

ChainLibraryVerification
solanatweetnaclnacl.sign.detached.verify()
evmethersethers.verifyMessage()

4. Token Issuance

The verify endpoint returns:

  • Access token (JWT) -- 15-min expiry, returned in body
  • Refresh token -- 90-day sliding expiry, set as httpOnly refresh_token cookie (each refresh pushes expiresAt forward)
  • logged_in=1 cookie -- non-httpOnly marker for frontend middleware
  • beta_access=1 cookie -- non-httpOnly beta gate

The signed JWT payload is:

{ sub: string; walletAddress: string; chain: string }  // `sub` is the user id

The JwtStrategy maps this to { id, walletAddress, chain }, exposed via @CurrentUser().

Google OAuth Flow

POST /api/v1/auth/google
{ "accessToken": "<google-access-token>" }

The backend verifies the Google token, deterministically derives a synthetic Solana wallet for the Google account, and creates / finds the user. Token issuance is identical to wallet sign-in.

A Google account is separate from any wallet account, even if the user later connects the same Solana wallet. Google login is for users who do not (yet) have a self-custody wallet -- they can still receive deposits because the synthetic wallet has a real Solana address. They cannot, however, sign DFlow transactions, so Kalshi-via-DFlow trading is not available on Google accounts.

Telegram Account Linking

Telegram is not a standalone sign-in method — accounts are created via wallet or Google, then optionally linked to a Telegram chat through the trading bot. Linking is handled by the telegram-bot module, not AuthController (User.authMethod is only "wallet" | "google").

Telegram posts updates to POST /api/v1/telegram-bot/webhook (public; validated against a secret_token). The bot's binding service (telegram-binding.service.ts) upserts a TelegramBinding row connecting the Telegram user to the Vezta account.

TelegramBinding Model

FieldTypeDescription
telegramIdBigInt (PK)Telegram user ID
userIdUUIDLinked Vezta user (unique)
chatIdBigIntTelegram chat ID for DM communication
walletTypestringconnected or created_via_bot
connectedAtDateTime?When the link was established
lastCommandAtDateTime?Last bot interaction

Token Refresh

When the access token expires, the client calls:

POST /api/v1/auth/refresh

Backend reads the refresh_token cookie (or accepts refreshToken in body for non-cookie clients), validates it, and performs token rotation:

  1. Mark the current refresh token as used
  2. Issue a new access token (15 min)
  3. Issue a new refresh token (90-day sliding expiry) in the same family
  4. Set new cookies

If a previously used refresh token is presented (indicating potential token theft), the entire token family is revoked.

This endpoint is rate-limited to 60 req/min keyed by token via the @RateLimit decorator (Redis-backed sliding window). The Google sign-in endpoint is limited to 10 req/min, /auth/wallet to 10 req/min, /auth/wallet/nonce to 20 req/min, and /auth/activate-key to 5 req/min.

Client-Side Token Storage

PlatformAccess TokenRefresh Token
Web (vezta-fe)In-memory variable (not localStorage)httpOnly cookie set by backend

The frontend stores the access token in-memory only -- never in localStorage or sessionStorage. This prevents XSS-based token theft. The logged_in=1 and beta_access=1 cookies are non-httpOnly so the Next.js middleware can read them for route protection, but they contain no sensitive data.

Auto-Refresh and Retry

Both lib/api/client.ts and lib/api/kubb-fetch.ts implement automatic 401 handling:

  1. An API call returns 401 Unauthorized
  2. The client attempts to refresh the access token via POST /auth/refresh
  3. If refresh succeeds, the original request is retried with the new token
  4. If refresh fails, the user is logged out

Both clients use a promise lock (refreshPromise) so that when several API calls fail with 401 simultaneously, only the first triggers a refresh -- all others wait for it. This prevents refresh storms.

Logout

POST /api/v1/auth/logout

Requires a valid access token. The backend invalidates all refresh tokens for the user and clears all three cookies.

Route Protection (Frontend)

proxy.ts is the Next.js middleware (exported as proxy and picked up natively by the framework). It checks both logged_in AND beta_access:

  • Protected routes (/portfolio, /settings, /copy, /rewards) -- Redirect to /login?redirect=... if either cookie is missing
  • Auth-only routes (no beta required) -- e.g., /access-key. Listed in AUTH_NO_BETA_ROUTES. Need logged_in but not beta_access.
  • Auth routes (/login, /access-key) -- Redirect to /terminal if both cookies are present
  • Public routes (/, /terminal, /markets, /top-traders, /events, /monitor, /ai-signals, etc.) -- Always accessible

proxy.ts also blocks bot user-agents on non-API routes (returns 403).

Global JWT Guard (Backend)

A global JwtAuthGuard is applied via APP_GUARD. Every route requires a valid JWT by default. To exempt a route, use the @Public() decorator:

@Public()
@Get('wallet/nonce')
async getNonce(@Query('address') address: string, @Query('chain') chain: string) {
  return this.authService.getNonce(address, chain);
}

The authenticated user is available via @CurrentUser():

@Post('logout')
async logout(@CurrentUser() user: JwtPayload) {
  await this.authService.logout(user.id);
}

WebSocket Authentication

JWT is passed in the Socket.IO handshake (handshake.auth.token). Public channels (market:*, monitor:*) are open; private channels (user:*) require a valid JWT and ownership verification. The gateway enforces:

  • 5000 max total connections
  • 10 connections per userId
  • 20 connections per IP
  • 30 messages/min per client (subscribe + unsubscribe combined)

On this page