Skip to main content

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

WorkerJob
Mission completionComplete running missions whose completesAt <= now, apply rewards deterministically
EnergyDerive energy lazily (stored + rate × elapsed), materialize on login/spend/staking updates
IndexerDecode staking events, update projections, publish staking.updated
LeaderboardSnapshot point ledger into leaderboard entries
OutboxDeliver 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.