Wallets & auth
FudFi is wallet-first. There are three ways to own an identity: an EVM wallet (primary, going forward), a Solana wallet (legacy, still supported), or Twitter OAuth. They can be linked together on one account.
Identity model
Two columns hold wallet identities on users:
| Column | Holds | Notes |
|---|---|---|
wallet_address | Solana (base58) | The original identity column. |
evm_address | EVM (lowercase hex) | unique. Added with the EVM auth flow. |
Never look a user up by one column. Always resolve through userIdentityCondition(users, { eq, or }, value) from backend/utils/identity.js, which matches Solana, raw EVM, and lowercased EVM input. It is applied in users, posts, votes, articles, replies, stats, payments, postProcessor, and adminAuth.
For frontend compatibility, an EVM-logged-in user also gets user.walletAddress = <evm address>, plus user.evmAddress and AuthContext.walletType ('solana' | 'evm'). That way headers, profile, and posting work unchanged.
EVM (primary)
EVM login is restricted to Robinhood Chain only:
| Network | Chain ID | Env |
|---|---|---|
| Robinhood Chain | 4663 | NEXT_PUBLIC_EVM_CHAIN=mainnet |
| Robinhood Chain Testnet | 46630 | NEXT_PUBLIC_EVM_CHAIN=testnet (default) |
The client UI is Reown AppKit (@reown/appkit + wagmi + Solana adapters, chains defined in frontend/src/lib/evm.ts). It needs NEXT_PUBLIC_REOWN_PROJECT_ID; if that is missing the EVM modal disables gracefully but hooks still work because WagmiProvider is always mounted.
1. Request a challenge
POST /api/auth/evm/challenge
Content-Type: application/json
{ "address": "0xAbC...", "chainId": 46630 }Returns the exact message to sign, the nonce, chainId, and expiresAt. Nonces are single-use and expire after 10 minutes. An unsupported chain returns 400.
2. Sign the message
Sign the returned message verbatim with the wallet (EIP-191 personal message).
3. Verify
POST /api/auth/evm/verify
Content-Type: application/json
{ "address": "0xAbC...", "message": "...", "signature": "0x...", "nonce": "..." }The nonce is consumed on use. Signatures are checked with ethers@5 verifyMessage (CJS-safe — do not upgrade to ethers v6 ESM). On success the user is found by evm_address or created with username evm_<first-6-hex>.
Solana (secondary / legacy)
Connect a Solana wallet and register — no signature required (legacy behavior, do not “fix” it):
POST /api/users/get-or-create
Content-Type: application/json
{ "walletAddress": "<base58>" }Sending an existing address returns the existing user; a new one is created with defaults.
Twitter OAuth
GET /api/auth/twitter # returns { authUrl, state }
GET /api/auth/twitter/callback # exchanges code, logs in or creates the userOAuth state is kept in an in-memory oauthStates map with a 10-minute TTL (fine for single-instance). Pass ?link=true&userId=<id> to link Twitter to an existing account instead of logging in.
Linking & unlinking
| Endpoint | Method | Notes |
|---|---|---|
/api/auth/link-evm | POST | Signature required (message, signature, nonce). |
/api/auth/link-wallet | POST | Links a Solana wallet. |
/api/auth/link-wallet / Twitter OAuth | — | Twitter links through the OAuth flow above. |
/api/auth/unlink-evm | POST | Blocked if it is the only method. |
/api/auth/unlink-wallet | POST | Blocked if it is the only method. |
/api/auth/unlink-twitter | POST | Blocked if it is the only method. |
The unlink guards count all three methods (Solana + EVM + Twitter). When editing auth, always check all three before allowing an unlink.
Session lookup
GET /api/auth/me?userId=<id> # returns { user }Environment
# frontend
NEXT_PUBLIC_REOWN_PROJECT_ID=...
NEXT_PUBLIC_EVM_CHAIN=testnet # or mainnet
NEXT_PUBLIC_EVM_RPC_URL=... # optional override for the testnet RPC
# backend (Twitter OAuth)
TWITTER_CLIENT_ID=...
TWITTER_CLIENT_SECRET=...
TWITTER_CALLBACK_URL=...