X-Change Guide

Auth & the wallet

The project uses sign-in-with-wallet: there is no password, no email. Proving you own a wallet's secret key is your identity. This page explains the full flow and the JWT layer on top.

The core idea

A wallet is just an ed25519 keypair (secret + public) stored in the browser. The address is derived from the public key. So:

  • The server cannot look up "your password" — there isn't one.
  • To prove who you are, you prove you hold the secret key.
  • Proving = signing a random challenge the server gives you.
  • Once proven, the server issues a JWT so you don't have to sign every request.

The exact same keypair also signs transactions. One key, two uses. Frontend see the frontend crypto page

The login flow, step by step

1 · Frontend creates a wallet
ed25519 keypair in the browser
secret key NEVER leaves the browser
generate_keypair
2 · POST /auth/challenge
send your address
server stores a random 16-byte challenge (10 min expiry)
returns { challenge }
3 · Browser signs the challenge
ed25519.sign(secret, challenge)
this is the "proof of ownership"
4 · POST /auth/login
address + pubkey + challenge + signature
verify_signed_by: address matches pubkey AND signature is valid
returns tokens
5 · Store JWTs
access (1h) + refresh (7d)
browser keeps them in localStorage
6 · /auth/me, /trade/order…
Authorization: Bearer <access>
require_auth middleware checks the JWT

Why a challenge instead of just signing your address?

If the server asked you to sign the same string every time, an attacker who captured one signature could replay it forever. A fresh random challenge that's single-use and expires in 10 minutes kills replay attacks. The challenge is stored in Postgres with a used flag.

JWT — the session layer

JWT = JSON Web Token. A signed blob of JSON that says "this address is authenticated". Two tokens are issued:

TokenTTLUsed for
access1 hourevery protected API call (header)
refresh7 daysPOST /auth/refresh to get a new pair
apps/node/src/api/middleware.rs — claims
1#[derive(Debug, Clone, Serialize, Deserialize)]
2pub struct Claims {
3 pub sub: String, // address ("subject")
4 pub typ: String, // "access" | "refresh"
5 pub iat: i64, // issued-at
6 pub exp: i64, // expiry
7}

The JWT is signed with HS256 (a shared secret from JWT_SECRET). The middleware verifies the signature, checks the type is "access", and reads sub — the address — which it places in the request for handlers to use.

require_auth — the middleware
1pub async fn require_auth(
2 State(state): State<AppState>,
3 mut req: Request,
4 next: Next,
5) -> Result<Response, Response> {
6 let header = req.headers().get(AUTHORIZATION) ... unwrap_or("");
7 let token = header.strip_prefix("Bearer ")
8 .ok_or_else(|| 401 "missing bearer token")?;
9 match verify_token(&state.cfg.jwt_secret, token, "access") {
10 Ok(sub) => { req.extensions_mut().insert(AuthedAddress(sub)); Ok(next.run(req).await) }
11 Err(_) => Err(401 "invalid or expired token"),
12 }
13}

The verification function

This one function is the heart of the auth and transaction system. It checks two things: the claimed address must be the derivation of the supplied public key, and the signature must validate under that key:

apps/node/src/wallet.rs
1pub fn verify_signed_by(pubkey_hex: &str, sig_hex: &str, msg: &[u8], claimed_address: &str) -> bool {
2 let pk = hex::decode(pubkey_hex)...;
3 let sig = hex::decode(sig_hex)...;
4 derive_address(&pk) == claimed_address && verify(&pk, msg, &sig)
5}
Defense point
Why both checks? If we only checked the signature, an attacker could pass their own pubkey that signed the message — but the address wouldn't match, so it's rejected. Binding key → address is what makes spoofing impossible.

Other middleware

CORS

A strict allowlist from ALLOWED_ORIGIN — never a wildcard. Only the configured frontend origins can call the API. Denies cross-origin requests by default if empty.

Rate limit

In-memory sliding window per client IP (default 240 req/min). Returns 429 when exceeded. Uses CF-Connecting-IP if behind Cloudflare.

Honest limitation
The rate limiter is per-node in-memory — fine for one instance. A real multi-node deployment would use something shared like Redis.

Where this is used