Rust backend
The backend is a modular monolith — one Cargo workspace, three deployables:
alpun-api Axum REST API + WebSocket gateway
alpun-worker Background jobs: missions, energy, leaderboards, outbox
alpun-indexer Solana staking indexer
Workspace layout
alpun-server/
├── Cargo.toml
├── apps/
│ ├── api/
│ ├── worker/
│ └── indexer/
└── crates/
├── alpun-domain/ Player, Alpaca, Mission, Inventory,
│ StakingPosition, Reward, GameSession
├── alpun-application/ StartMission, CompleteMission,
│ CollectResource, ClaimReward, SynchronizeStake
├── alpun-infrastructure/ Mongo repositories, HTTP clients, config,
│ event publication
├── alpun-game-engine/ Energy calculations, mission results,
│ cooldowns, XP, progression, anti-cheat
├── alpun-blockchain/ Solana RPC, instruction building,
│ transaction verification, account decoding
├── alpun-auth/ Wallet challenges, signature validation,
│ access tokens, refresh sessions
└── alpun-shared/ IDs, time, errors, common types
Core dependencies: Tokio, Axum, Tower/tower-http, Serde, the official MongoDB Rust driver, Tracing, Thiserror, jsonwebtoken, ed25519-dalek.
Shared state
#[derive(Clone)]
pub struct AppState {
pub database: mongodb::Database,
pub auth_service: Arc<AuthService>,
pub game_service: Arc<GameService>,
pub staking_service: Arc<StakingService>,
pub solana_client: Arc<SolanaClient>,
}
One MongoDB client is created at startup and reused; the driver manages pooling and topology.
Authentication
Primary authentication is Keycloak OIDC — the same identity instance
already running in the cluster (https://identity.bitview.club), with a
dedicated alpun realm and alpun-app client (Authorization Code +
PKCE).
The backend validates RS256 access tokens against Keycloak's JWKS, cached in-process:
KEYCLOAK_ISSUERS accepted iss values (CSV)
KEYCLOAK_AUDIENCE accepted aud values (CSV)
KEYCLOAK_JWKS_CACHE_TTL_SECONDS JWKS cache lifetime
Players are keyed by the Keycloak subject (sub claim). A Solana
wallet is optional and only required for staking and NFT ownership.
Wallet linking
Linking a wallet to the account uses an Ed25519 signature over a domain-bound nonce challenge. Both endpoints require a valid Keycloak bearer token:
1. POST /api/v1/wallet/challenge — backend generates a random nonce,
stores it in MongoDB with a short expiration.
2. Wallet signs the challenge text.
3. POST /api/v1/wallet/link — browser sends public key + signature.
4. Backend verifies the Ed25519 signature, marks the challenge consumed,
and stores the wallet on the player document.
Example challenge text:
Alpaca Universe wallet link
Domain: alpun.io
Wallet: 7x...
Nonce: 9c5c...
Issued at: 2026-07-17T11:00:00Z
Expires at: 2026-07-17T11:05:00Z
This request does not initiate a blockchain transaction.
Security requirements:
- Nonce usable once, five-minute expiration, bound to domain and wallet.
- Never ask for a seed phrase; never store private keys.
- One wallet per account; relinking requires an explicit unlink flow.
- Rate limits by account, wallet and IP.
- Session lifecycle (refresh, logout) belongs to Keycloak, not the game backend.
Idempotency
Every important operation carries an idempotency key:
mission-start:<player>:<mission>:<request-id>
mission-complete:<player-mission-id>
reward-claim:<season>:<player>
blockchain-event:<signature>:<event-index>
A unique index on the key makes double-crediting impossible at the database level.
Optimistic concurrency
Mutable documents carry a version field. Updates filter on
{_id, version} and increment; zero matched documents means a concurrent
write happened — reload and retry or reject.
Server-controlled randomness
seed = HMAC(server_secret,
player_id + mission_id + started_at + random_nonce)
The result (or committed seed) is stored when a mission starts. The browser never chooses or rerolls random values.
Transactions and the outbox
Multi-collection changes run in a MongoDB transaction, and every
side-effect that must reach the outside world is written as an
outbox_events document in the same transaction:
Complete mission
├── mark player_mission complete
├── add inventory ledger entries
├── update inventory balance
├── add seasonal points
└── insert outbox event
A worker publishes pending outbox events (WebSocket pushes, notifications) after commit, so no event can be lost between the database and the bus.
Background workers
| Worker | Job |
|---|---|
| Mission completion | Complete running missions whose completesAt <= now, apply rewards deterministically |
| Energy | Derive energy lazily (stored + rate × elapsed), materialize on login/spend/staking updates |
| Indexer | Decode staking events, update projections, publish staking.updated |
| Leaderboard | Snapshot point ledger into leaderboard entries |
| Outbox | Deliver pending events |
Observability
Prometheus metrics exposed by every deployable:
http_requests_total, http_request_duration_seconds
websocket_connections
game_commands_total, game_commands_rejected_total
missions_started_total, missions_completed_total
rewards_issued_total, reward_duplicate_attempts_total
solana_rpc_requests_total, solana_rpc_errors_total
blockchain_indexer_slot, blockchain_indexer_lag_slots
mongodb_operation_duration_seconds
Structured tracing fields: request_id, player_id, wallet,
session_id, command_id, mission_id, transaction_signature, slot.
Never log access tokens, wallet signatures used for authentication, or challenge data.