🔐 Security Architecture

How Your Funds
Are Protected

Every claim on this page is backed by a specific line of deployed code. No vague promises — each section references the exact file and mechanism that enforces it.

Anchor Smart Contract
PDA Vault Custody
24h Emergency Timelock
bcrypt Password Hashing
CSPRNG All Randomness
DB Replay Protection

Layer 1 — Blockchain
On-Chain Escrow (Anchor Program)
SOL and CRYPTAN token wagers are held in program-derived vaults — not in the server wallet. The server cannot take funds without executing a valid on-chain instruction.
🔒 SOL Wager Lifecycle
1
Server calls initializeGame Creates a PDA escrow account and a separate PDA vault to hold lamports. Entry fee, house fee bps, and max players are locked into the on-chain state at this point. on-chain
2
Player calls deposit from Phantom The program enforces: game must be Open, not full, wallet must not have already deposited. The exact entry_fee lamports move from player → vault PDA. Server cannot intercept. on-chain
3
Server calls verifyDeposit Reads the on-chain GameEscrow account and checks the player's public key is in the registered players array. Only then does the server grant lobby access. escrow.js
4
Game ends — server calls finalize Payouts array must sum to exactly 10 000 bps (enforced by the contract). Contract verifies each recipient wallet matches a registered player at that index. Arithmetic uses checked_mul / checked_div to prevent overflow. on-chain
5
Game cancelled — server calls cancel Full entry fee returned to every registered player. No house fee on cancel. Server must be the original authority (has_one = authority) to call this. on-chain
PDA Vault — No Direct Access
Vault lamports are held at a Program Derived Address seeded with [b"vault", game_id]. Only the Anchor program can sign transfers out of it — no EOA has the private key.
lib.rs — vault PDA seeds
Payout Sum Enforced by Contract
The finalize instruction asserts total_bps == 10_000 before moving any funds. If the payouts array doesn't sum to 100%, the transaction reverts on-chain.
lib.rs — InvalidPayoutSplit
🪙
CRYPTAN Token Track (SPL)
Token escrow mirrors SOL escrow using a PDA-owned token account. The contract verifies ATA owner and mint match before every transfer. Wrong mint = automatic revert (WrongMint error).
lib.rs — token_finalize
🚫
Duplicate Deposit Blocked On-Chain
The contract iterates the registered players array and requires the wallet is not already present (AlreadyDeposited). This is enforced at the program level, not just by the server.
lib.rs — deposit instruction
Layer 1 — Trustless Escape Hatch
Emergency Refund (No Server Required)
If Cryptan's servers disappear permanently, deposited players are not stuck. After 24 hours any deposited player can reclaim their funds directly from the contract — no authority signature needed.
⚡ emergency_refund / token_emergency_refund
1
24-hour timelock opens Contract checks Clock::get()?.unix_timestamp >= escrow.created_at + 86_400. No authority signature is required — any deposited player can initiate. on-chain
2
Caller is verified as deposited player Contract finds the caller's public key in the players array. Non-players cannot call it (NotAPlayer error). Each player must call it separately for their own refund.
3
Exact entry_fee returned to caller The exact entry_fee stored at deposit time is transferred back. Callable from Solana Explorer, any wallet, or a simple script — zero Cryptan server involvement. on-chain
⚠ Honest Caveat
  • The 24-hour timelock means players cannot emergency-refund during an active game (by design — it prevents griefing mid-game).
  • The escrow monitor on the server auto-cancels stale rooms after 23 hours, which fires before the on-chain timelock — so in practice, players rarely need to call this themselves.
  • This guarantee applies equally to both SOL and CRYPTAN token wager rooms. Both tracks use a PDA vault and the same 24-hour timelock. Both room types require ESCROW_PROGRAM_ID — token wagers without it are refused at the server level before any funds are touched.

Layer 2 — Server
Replay Protection & Double-Spend Prevention
Even if the blockchain is queried, multiple server-side layers prevent the same payment from being applied more than once.
🗄
Signature Registry (DB)
Every processed transaction signature is written to used_signatures with ON CONFLICT (signature) DO NOTHING. A UNIQUE INDEX on the signature column ensures this is enforced at the database level, not just in code.
db.js — used_signatures_signature_key
🔒
Per-Room Refund Lock (In-Process)
Before any SOL or token refund, the server claims a refundLocks Set entry keyed to refund:{username}:{type}. If already held, the second transfer is skipped. Prevents disconnect handler racing the escrow monitor on the same room.
socketHandler.js — refundLocks
🏆
Atomic Tournament Prize Guard
Prize distribution uses UPDATE tournaments SET prize_distributed=true WHERE id=$1 AND prize_distributed=false RETURNING id. Only one concurrent path can get a non-zero rowCount — the second path exits immediately.
socketHandler.js — _distributeTournamentPrizes
🔢
Integer Lamport Arithmetic
All SOL → lamport conversions use BigInt(Math.round(amount * LAMPORTS_PER_SOL)) instead of float multiplication. Prevents IEEE-754 drift (e.g. 0.1 SOL × 1e9 = 99,999,999 instead of the correct 100,000,000).
solana.js — solToLamports()
💾
Wager Room Persistence
All active wager and tournament rooms are snapshotted to Postgres on every state change. On restart, rooms are restored as ghost sessions. Players can reconnect and the game continues — no funds lost to a server crash.
socketHandler.js — persistRoom()
Escrow Monitor (Auto-Cancel)
A background loop runs every 5 minutes. Any wager room stuck for more than 23 hours (no game started or no winner declared) is automatically cancelled and all deposited players are refunded — no manual action required.
escrowMonitor.js — scanStaleRooms()
Layer 3 — Authentication
Identity & Access Security
Player accounts, sessions, and sensitive actions are protected by multiple independent controls.
🔑
bcrypt Password Hashing
Passwords are hashed with bcrypt at cost factor 12 — roughly 250ms per hash on modern hardware, making offline brute-force impractical. Raw passwords are never stored or logged.
authController.js — BCRYPT_ROUNDS=12
🎫
HMAC-SHA256 JWT
Session tokens are custom HMAC-SHA256 JWTs with a 7-day expiry. The server hard-fails at startup if JWT_SECRET is missing — there is no fallback to an insecure default key.
authController.js — generateToken()
🎲
Cryptographic Randomness (CSPRNG)
OTP codes use crypto.randomInt. Room IDs use crypto.randomBytes(3). Neither relies on Math.random(), whose V8 internal state can be partially recovered from observed outputs.
socketHandler.js — generateOTP / generateRoomId
🛡
Rate Limiting (Token Bucket)
All sensitive events have per-user, per-IP, and per-socket rate limits. Auth events (login, register, OTP verify) are keyed by IP address — reconnecting with a new socket ID does not reset the bucket.
rateLimiter.js — IP_KEYED_EVENTS
📧
OTP Persistence Across Restarts
Pending verification and password-reset OTPs are written to the otp_store table on every mutation. A server restart does not invalidate a code the user is mid-way through entering. Codes expire after 10 minutes and have a max of 5 attempts.
socketHandler.js — otpSet / otpGet
Timing-Safe Admin Comparison
Admin password verification uses crypto.timingSafeEqual() — not a string equality operator. This prevents timing side-channel attacks that could leak password length or prefix through response time differences.
adminRoutes.js — safeCompare()
Layer 4 — Transport
HTTP Security Headers
Configured via Helmet on every response. Prevents a class of client-side attacks regardless of application-level bugs.
Header Value / Policy Protects Against
Content-Security-Policy strict sha256 hashes for inline scripts; default-src 'self' XSS, script injection, data exfiltration
HSTS production only max-age=31536000, includeSubDomains, preload SSL stripping, protocol downgrade
Referrer-Policy strict-origin-when-cross-origin URL leakage to third-party resources
frame-ancestors 'none' Clickjacking via iframe embedding
object-src 'none' Flash/Silverlight plugin attacks
base-uri 'self' <base> injection attacks
CORS env-configured ALLOWED_ORIGIN; defaults to block-all in production Cross-origin API abuse
Body limit express.json({ limit: '2mb' }) JSON payload DoS
Layer 5 — Resilience
Disconnect & Crash Recovery
What happens to funds and game state if a player drops or the server restarts.
🔄
Mid-Game Disconnect Grace (3 min)
A disconnected player in a wager game has 3 minutes to reconnect before a bot takes their seat. If they reconnect within the window, the game continues normally — no refund needed. Karma penalty only if they fail to return.
socketHandler.js — CRYPTAN_RECONNECT_GRACE_MS
🏛
Lobby Disconnect Grace (15 sec)
A player who disconnects from the pre-game lobby has 15 seconds to reconnect before being removed. If they paid and are the last player, the escrow is cancelled and fully refunded on-chain before the room is deleted.
socketHandler.js — lobby grace timer
💾
State Survives Server Restart
Wager rooms are persisted to Postgres on every game state change. On startup, rooms active in the last 24 hours are restored. Players can reconnect via the normal reconnect flow and the game picks up where it left off.
socketHandler.js — restoreWagerRooms()
Full Transparency
What Is and Isn't Guaranteed
Not everything is trustless. Here's an honest breakdown.
Scenario Guarantee Level Condition
SOL wager — correct payout Trustless ESCROW_PROGRAM_ID configured
SOL wager — refund if server disappears Trustless Call force_cancel (any deposited player, all refunded at once) or emergency_refund (per-player) after 24 h — no server needed
CRYPTAN token — correct payout (on-chain) Trustless Both ESCROW_PROGRAM_ID and CRYPTAN_MINT_ADDRESS set
CRYPTAN token wager — payout Trustless PDA token vault enforced by contract — same guarantees as SOL wagers. Room creation refused without ESCROW_PROGRAM_ID
Tournament prizes Trust-minimised Atomic DB guard prevents double-pay; on-chain transfer per winner
Free-play game integrity Server-side No crypto at stake; game logic runs server-side, not on-chain
Account password security Strong bcrypt-12; JWT signed with env secret; OTPs CSPRNG-generated
⚠ Known Limitations
  • Game result is determined server-side. The Anchor program enforces that payouts sum to 100% and go to registered players — but it trusts the server to identify the correct winner. The game logic itself does not run on-chain.
  • Token wager rooms are fully trustless. Rooms are refused at creation without ESCROW_PROGRAM_ID — no custodial fallback exists. Both SOL and CRYPTAN wagers use PDA vaults with identical guarantees.
  • Per-process refund lock. Fixed — refundLocks Set replaced with pg_try_advisory_lock. The lock is now held at the Postgres level and is visible across all server instances sharing the same database.
  • Emergency refund is per-player. Fixed — new force_cancel / force_token_cancel instructions added to the contract. Any single deposited player can call them after 24 h to refund all players at once in one transaction.
Prove It Yourself
How to Independently Verify
You don't need to trust this page — here's how to check each claim on-chain.
🔍
Inspect the Escrow Vault
Derive the vault PDA using seeds ["vault", roomId] and the program address. Check its lamport balance on Solana Explorer. The balance should equal entry_fee × player_count while the game is active.
lib.rs — VAULT_SEED
📋
Read the Escrow Account
Fetch the GameEscrow PDA (seeds: ["escrow", roomId]) with Anchor CLI or program.account.gameEscrow.fetch(). You can see players, entry_fee, status, and created_at directly.
escrow.js — fetchEscrow()
🕐
Verify Emergency Refund Eligibility
Check escrow.created_at + 86400 <= Clock::unix_timestamp. If true and the game isn't finalized or cancelled, call emergency_refund from any Solana wallet client using only the room ID and your wallet — no server needed.
lib.rs — emergency_refund
📜
Audit Transaction History
Every finalize, cancel, and emergency_refund emits on-chain events (GameFinalized, GameCancelled, EmergencyRefunded) that are permanently visible on any Solana explorer using the program address.
lib.rs — #[event] declarations