Auth and HD wallets
Sign-in is EIP-4361 (SIWE): the user proves control of a wallet address by signing a message, and gets back a JWT. There is no password, no email, and no account record to create beforehand — the first successful signature is the registration.
The flow
GET /auth/nonce → { nonce }
↓ client assembles the SIWE message and signs it in the wallet
POST /auth/verify { message, signature } → { token, address }Server side, in auth/auth.controller.ts:61-82, in this order:
siwe.verify(message, signature)→ the address and the nonce carried in the message.nonces.redeem(nonce)— the nonce is burned before the user is created.users.findOrCreate(address).jwt.sign(user.id).
Step 2 comes before step 3 on purpose (commented at auth.controller.ts:77). Reverse them and a
replayed signature would create the user before the redemption failed.
POST /auth/verify answers 200, not the Nest default 201 for @Post — an explicit
@HttpCode(200) at auth.controller.ts:42.
The client assembles the message with the same siwe library rather than by hand
(apps/web/src/shared/lib/siwe-message.ts:17), so the text it signs and the text the server parses
cannot drift apart.
What the signature check actually covers
siwe.verifier.ts:70 calls parsed.verify({ signature, domain }). The library checks the domain
(from SIWE_DOMAIN), expirationTime, notBefore, and the signature itself.
chainId is not verified. It is not passed into parsed.verify(). The client does put a
chainId into the signed message (siwe-message.ts:24, from wagmi’s useChainId()), so it is
covered by the signature — but the server never compares it against an expected value such as
DERIVE_CHAIN_ID. Searching git grep chainId -- apps/api/src/auth finds only the spec file.
Every rejection returns the same opaque SIWE_REJECTED with no detail (auth.controller.ts:62).
That is a deliberate choice against probing, not thin error handling.
Nonces
Stored in Postgres, table auth_nonces (nonce varchar(64) PK, issued_at, expires_at, used_at).
TTL is 5 minutes (NONCE_TTL_MS in auth/auth.module.ts:16).
Replay protection is a single atomic statement, not a read-then-write:
UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING ...at auth/nonce.store.ts:30-44. Covered by nonce.store.integration.spec.ts:38, which redeems the
same nonce concurrently and asserts exactly one winner.
This is the UPDATE-returns-a-tuple trap again (nonce.store.ts:31-36). A forgotten
destructuring here silently breaks replay protection rather than throwing.
There is no cleanup job. auth_nonces_expires_idx was created for a sweeper that does not
exist; git grep auth_nonces finds only the migration and the store. The table grows without bound.
Tokens
An access JWT and nothing else. There are no refresh tokens — no endpoint, no field, no rotation.
The payload is only { sub: userId }, deliberately without the wallet address
(auth/jwt.service.ts:16). TTL is '7d', hardcoded in auth/auth.module.ts:17 rather than read
from the environment. The secret is JWT_SECRET (minimum 32 characters).
The browser keeps the token in sessionStorage, not localStorage, bound to the address it was
issued for (apps/web/src/shared/api/token-store.ts): on read the stored address is compared with
the connected one, and a mismatch is treated as no token at all. After seven days the user signs in
again.
Authorising a request
AuthGuard(auth/auth.guard.ts:16) readsAuthorization: Bearer <token>and setsrequest.user = { userId }.- There is no global guard — no
APP_GUARDinapp.module.ts. Protection is explicit, per controller. @Authenticated()(auth/authenticated.decorator.ts:23) combines@UseGuards(AuthGuard)with@ApiBearerAuth()in one mark.@CurrentUser()(auth.guard.ts:50) extractsuserIdand throws a singleUNAUTHORIZEDif the guard did not run.
The two halves of @Authenticated() were once applied separately, and PositionsController and
SubaccountsController ended up guarded at runtime but not marked secure in OpenAPI — so the
generated client never sent the header. That is why they are one decorator now, and why
authenticated.decorator.spec.ts:46 asserts both halves together.
Protected controllers: portfolio, positions, subaccounts, treasury, withdrawals. Public routes are
simply the ones without the decorator — auth.controller.ts and everything market-data.
Users and HD index
UsersRepository.findOrCreate (auth/users.repository.ts:25) normalises the address to EIP-55
checksum form with getAddress before querying, then
INSERT ... ON CONFLICT (wallet_address) DO NOTHING and re-reads.
Table users: id uuid PK, wallet_address varchar(42) UNIQUE, hd_index integer UNIQUE, created_at.
The HD index comes from a sequence, users_hd_index_seq, not from max+1 — two users signing in at
the same moment from different addresses would otherwise race for the same index
(users.repository.ts:35).
That index is the link between identity and money. HdWallet (auth/hd-wallet.ts:6) derives
m/44'/60'/0'/0/{hd_index} from MASTER_MNEMONIC — a standard path, so the addresses are
recoverable by any ordinary wallet.
MASTER_PRIVATE_KEY cannot substitute for MASTER_MNEMONIC: child addresses cannot be derived from
a bare private key. They are two different secrets with two different jobs.
HdWallet is provided as AUTH_DEPS.HD_WALLET and exported to treasury, where
deposit-watcher.ts, gas-funder.ts and sweeper.ts use it. GET /deposit-address
(treasury/treasury.controller.ts:35) is the whole chain in one place: JWT → userId →
findById → hdIndex → HdWallet.addressFor. hd-wallet.spec.ts:28 asserts that the address
shown to the user and the address that signs the sweep are the same one.