Skip to main content

Architecture overview

SolBid is built on a modern 3-tier architecture that combines blockchain permanence with real-time web performance. This page provides a comprehensive overview of how the components work together.

High-level architecture

┌─────────────────────────────────────────────────────────────┐
│                    Next.js Frontend                         │
│  ┌──────────────┐  ┌──────────────┐  ┌─────────────────┐   │
│  │   React UI   │  │  API Routes  │  │  Solana Client  │   │
│  └──────────────┘  └──────────────┘  └─────────────────┘   │
└────────────┬────────────────┬────────────────┬─────────────┘
             │                │                │
             │ WebSocket      │ REST API       │ RPC Calls
             │                │                │
┌────────────▼────────┐  ┌────▼────────┐  ┌───▼──────────────┐
│   WebSocket Server  │  │  Database   │  │  Solana Program  │
│   (Node.js + WS)   │  │  (Prisma)   │  │     (Rust)       │
└─────────────────────┘  └─────────────┘  └──────────────────┘
         │                                         │
         │                                         │
         └────── Real-time Game Updates ──────────┘

Solana Program

On-chain smart contract handling game logic, bid validation, and prize distribution

Next.js Application

Full-stack web app with React UI, API routes, and Solana integration

WebSocket Server

Real-time game state synchronization across all connected clients

Tier 1: Solana smart contract

The core game logic runs entirely on-chain via a Rust-based Solana program located in programs/src/.

Program structure

// programs/src/lib.rs
use solana_program::entrypoint;
use processor::process_instruction;

pub mod instructions;
pub mod processor;
pub mod state;
pub mod error;
pub mod utils;

entrypoint!(process_instruction);
The program follows the standard Solana program architecture with:
  • Entry point: process_instruction handles all incoming instructions
  • Instructions: Define the available operations (CreateGame, PlaceBid)
  • State: Define account data structures (GameState, PlayerState, Bid)
  • Processor: Routes instructions to handler functions
  • Utils: Helper functions for PDAs and data deserialization

Account structure

SolBid uses Program Derived Addresses (PDAs) to store game data:

Game PDA

// programs/src/state.rs:4
pub struct GameState {
  pub game_id: u64,
  pub initial_bid_amount: u64,
  pub highest_bid: u64,
  pub last_bid_time: u64,
  pub total_bids: u64,
  pub last_bidder: Pubkey,
  pub prize_pool: u64,
  pub platform_fee_percentage: u64,
  pub game_ended: bool,
}
Derived using: [b"game", game_id]

Player PDA

// programs/src/state.rs:17
pub struct PlayerState {
  pub total_bid_amount: u64,
  pub safe: bool,
  pub royalty_earned: u64,
  pub bid_count: u64,
}
Derived using: [b"player", game_id, player_pubkey, bid_count]
Each bid creates a new Player PDA because the bid count changes. This allows the program to track all player interactions throughout the game.

Bid PDA

// programs/src/state.rs:25
pub struct Bid {
  pub bidder: Pubkey,
  pub amount: u64,
  pub timestamp: u64,
}
Derived using: [b"bid", game_id, bid_number]

Core instructions

CreateGame

Creates a new game with an initial bid:
// programs/src/instructions/create_game.rs:22
pub fn create_game(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    game_id: u64,
    initial_bid_amount: u64,
) -> ProgramResult
Process flow:
1

Validate initial bid

Ensures the initial bid is at least 14,000,000 lamports (0.014 USDC).
2

Derive PDAs

Calculates Game, Player, and Bid PDA addresses using the game ID and player public key.
3

Create accounts

Uses invoke_signed to create the three PDA accounts with appropriate rent and space.
4

Initialize state

Serializes GameState, PlayerState, and Bid data into the account data.
5

Transfer initial bid

Transfers the bid amount from the player to the Game PDA.

PlaceBid

Adds a new bid to an existing game:
// programs/src/instructions/place_bid.rs:16
pub fn place_bid(
  program_id: &Pubkey,
  accounts: &[AccountInfo],
  bid_amount: u64,
  bid_count: u64,
) -> ProgramResult
Process flow:
1

Deserialize game state

Loads current game data from the Game PDA.
2

Check if game ended

Verifies the game hasn’t already ended and checks if the timer expired.
3

Validate bid amount

Ensures bid is at least 2x the current highest bid.
4

Validate bid count

Prevents race conditions by verifying bid_count matches total_bids + 1.
5

Create new accounts

Creates new Player and Bid PDAs for this bid entry.
6

Update game state

Updates highest_bid, last_bidder, total_bids, prize_pool, and last_bid_time.
7

Transfer bid to pool

Transfers bid amount to the Game PDA.

Prize distribution

When the timer expires, the end_game function handles prize distribution:
// programs/src/instructions/place_bid.rs:153
pub fn end_game<'a, 'b: 'a>(
  program_id:  &Pubkey,
  game_id: u64,
  accounts: &'a [AccountInfo<'b>],
  game_state: &mut GameState,
) -> ProgramResult
Distribution logic:
1

Fetch bid history

Retrieves all Bid PDAs from the accounts array using fetch_bid_history.
2

Determine distribution model

Games with fewer than 5 bids use simplified distribution (winner gets 90%, platform 10%).
3

Calculate royalties

For 5+ bid games, calculates weighted royalties for early bidders using the cal_royalties function.
4

Transfer platform fee

Sends 10% of the last 5 bids to the platform account.
5

Transfer winner prize

Sends remaining balance to the last bidder’s account.
6

Update player states

Marks players as “safe” and updates their royalty_earned fields.
7

End the game

Sets game_ended to true, preventing further bids.
The prize distribution happens atomically in a single transaction. If any transfer fails, the entire transaction reverts.

Tier 2: Next.js application

The frontend is a full-stack Next.js 14 application located in next-app/.

Technology stack

  • Framework: Next.js 14 with App Router
  • UI: React 18 with Tailwind CSS and Radix UI components
  • Blockchain: Solana Web3.js + Wallet Adapter
  • Authentication: NextAuth.js with Prisma adapter
  • Database: PostgreSQL via Prisma ORM
  • State management: React Context API
  • Real-time: WebSocket client

Solana integration

The app integrates with Solana through several key modules:

Transaction building

// next-app/solana/game.ts:13
export const createGame = async (
  publicKey: PublicKey, 
  gameId: number, 
  bidAmount: number
) => {
  // Serialize instruction data
  const instructionData = Buffer.concat([
    Buffer.from([0]),  // CreateGame discriminator
    gameIdBuffer,
    initialBidAmountBuffer,
  ])

  // Build transaction instruction
  const createGameIx = new TransactionInstruction({
    keys: [
      { pubkey: gamePda, isSigner: false, isWritable: true },
      { pubkey: publicKey, isSigner: true, isWritable: true },
      { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
      { pubkey: playerPda, isSigner: false, isWritable: true },
      { pubkey: bidPda, isSigner: false, isWritable: true },
    ],
    programId: PROGRAM_ID,
    data: instructionData,
  })

  return { instructions: [createGameIx], totalCost: initialBidLamports, gamePda, playerPda, bidPda }
}

PDA derivation

// next-app/solana/pda.ts:5
export function getGamePda(gameId: BN): PublicKey {
  const gameIdBuffer = Buffer.from(gameId.toArray('le', 8));  
  const [gamePda] = PublicKey.findProgramAddressSync(
    [Buffer.from('game'), gameIdBuffer],
    PROGRAM_ID
  );
  return gamePda;
}
PDA derivation must exactly match the on-chain program’s logic to ensure address consistency.

Transaction execution

The useTransaction hook manages transaction lifecycle:
// From next-app/components/game/CreateGame.tsx:63
const txId = await execute(async () => {
  const { instructions, totalCost, gamePda, playerPda, bidPda } =
    await createGame(publicKey, gameId, bidAmount);
  
  return { instructions, totalCost };
});
The hook handles:
  • Transaction construction
  • Wallet signing
  • Network submission
  • Confirmation polling
  • Error handling

API routes

Next.js API routes provide backend functionality:
RoutePurpose
/api/gameCreate game records in database
/api/bidRecord bids and retrieve player/bid PDAs
/api/pdaFetch all PDAs for a game
/api/auth/*Authentication and user management
/api/dashboardAggregate game and transaction data
API routes serve as the bridge between on-chain data and the off-chain database, ensuring data consistency and enabling fast queries.

Component architecture

Key React components:

CreateGame component

// next-app/components/game/CreateGame.tsx:28
export default function CreateGame({ onCreateGame }: CreateGameProps) {
  const { sendMessage } = useSocket();
  const { publicKey } = useWallet();
  const { status, execute, resetStatus } = useTransaction(CONNECTION);
  
  // 1. Build and execute transaction
  // 2. Store game in database via API
  // 3. Broadcast to WebSocket server
  // 4. Update local UI state
}

PlaceBid component

// next-app/components/game/PlaceBid.tsx:33
export default function PlaceBid({
  gameId,
  bidCount,
  currBid,
  onPlaceBid,
}: PlaceBidModalProps) {
  // Validates bid is 2x current bid
  // Checks if game ended during transaction
  // Handles race conditions with bid count verification
}

Tier 3: WebSocket server

The WebSocket server (ws/src/) provides real-time game state synchronization.

Server implementation

// ws/src/app.ts:5
function main() {
  const server = createHttpServer();
  const wss = new WebSocketServer({ server });

  wss.on("connection", handleConnection);

  const PORT = process.env.PORT ?? 8080;
  server.listen(PORT, () => {
    console.log(`WS server is running on port ${PORT}`);
  });
}

Connection handling

// ws/src/connectionHandler.ts:7
export function handleConnection(ws: WebSocket) {
  const gameManager = GameManager.getInstance();
  gameManager.addClient(ws);

  ws.on("message", async (raw: Buffer) => {
    const { data } = JSON.parse(raw.toString());
    const token = data?.token;
    
    // Verify JWT token
    const authorized = await verifyToken(ws, token);
    if (!authorized) {
      sendError(ws, "Unauthorized");
      ws.close();
      return;
    }
    
    handleMessage(ws, raw.toString());
  });
}
All WebSocket connections are authenticated using JWT tokens generated by NextAuth. Unauthenticated clients are immediately disconnected.

Game state management

The GameManager singleton maintains in-memory game state:
// ws/src/gameManager.ts:7
class GameManager {
  private static instance: GameManager;
  private games: Map<number, Game> = new Map();
  private clients: Set<WebSocket> = new Set();

  addGame(game: Game) {
    if (!this.games.has(gameId)) {
      this.games.set(gameId, game);
      this.broadcastNewGame(game);
    }
  }

  updateGame(gameId: number, data: any) {
    const game = this.games.get(gameId);
    if (game) {
      this.updateGameData(game, data);
      this.broadcastBidUpdate(game);
    }
  }
}

Message types

Message TypeDirectionPurpose
create-gameClient → ServerNotify server of new game creation
place-bidClient → ServerNotify server of new bid
new-gameServer → ClientsBroadcast new game to all clients
game-updateServer → ClientsBroadcast bid update to all clients

Data flow

Creating a game

1. User clicks "Create Game" in UI
   └─> next-app/components/game/CreateGame.tsx:37

2. Frontend builds transaction instruction
   └─> next-app/solana/game.ts:13

3. User approves transaction in wallet
   └─> @solana/wallet-adapter-react

4. Transaction submitted to Solana
   └─> programs/src/instructions/create_game.rs:22

5. Smart contract creates PDAs and initializes state
   └─> Solana blockchain

6. Frontend stores game in database
   └─> next-app/app/api/game/route.ts

7. Frontend broadcasts to WebSocket server
   └─> ws/src/gameManager.ts:31

8. WebSocket server broadcasts to all clients
   └─> All connected browsers receive update

Placing a bid

1. User enters bid amount in PlaceBid modal
   └─> next-app/components/game/PlaceBid.tsx:49

2. Frontend validates bid is 2x current bid
   └─> Client-side validation

3. Frontend fetches existing player/bid PDAs
   └─> next-app/app/api/bid/route.ts (GET)

4. Frontend builds transaction with all required accounts
   └─> next-app/solana/bid.ts:14

5. Transaction submitted to Solana
   └─> programs/src/instructions/place_bid.rs:16

6. Smart contract validates and updates state
   └─> Validates bid amount, creates new PDAs, transfers SOL

7. Frontend checks if game ended
   └─> next-app/solana/game.ts:50

8. Frontend stores bid in database
   └─> next-app/app/api/bid/route.ts (POST)

9. WebSocket broadcast to all clients
   └─> Real-time update across all browsers

Security considerations

On-chain security

1

PDA validation

Every instruction validates that provided PDAs match expected derivations:
if *game_account.key != game_pda {
  return Err(BiddingError::InvalidGameAccount.into());
}
2

Bid validation

The smart contract enforces the 2x minimum bid rule and timer expiration on-chain, preventing client-side manipulation.
3

Atomic distributions

Prize distributions happen in a single transaction, ensuring either complete success or complete failure.
4

Rent exemption

All accounts maintain rent-exempt balances to prevent account closure.

Off-chain security

1

JWT authentication

WebSocket connections require valid JWT tokens issued by NextAuth.
2

Input validation

All user inputs are validated both client-side and server-side before blockchain submission.
3

Database integrity

Prisma ORM prevents SQL injection and enforces data type constraints.

Performance optimizations

Transaction optimization

  • Account chunking: For games with many bids, the placeBidScale function chunks account arrays to stay within Solana’s transaction size limits
  • Parallel account creation: Game, Player, and Bid accounts are created in a single transaction using invoke_signed

Frontend optimization

  • Optimistic UI updates: UI updates immediately while waiting for transaction confirmation
  • WebSocket debouncing: Game state updates are debounced to prevent excessive re-renders
  • Connection pooling: Shared Solana RPC connection across components

Backend optimization

  • In-memory game state: WebSocket server caches active games in memory for instant broadcasts
  • Database indexing: Game ID, user ID, and timestamp fields are indexed for fast queries
The combination of on-chain state storage and off-chain caching provides both data permanence and real-time performance.

Deployment architecture

For production deployments:
┌─────────────────┐
│   Vercel/CDN    │  Next.js frontend (edge deployed)
└────────┬────────┘

┌────────▼────────┐
│  WebSocket VPS  │  Node.js WebSocket server
└────────┬────────┘

┌────────▼────────┐
│   PostgreSQL    │  Managed database (e.g., Supabase)
└─────────────────┘

┌─────────────────┐
│  Solana Devnet  │  Smart contract deployment
└─────────────────┘

Configuration

Key configuration values:
// next-app/lib/constant.ts:3
export const PROGRAM_ID = new PublicKey('71Q2euxEEAzcqFBNNgAgUaTTE63dJaVioLmKx7AKMpSB');
export const CONNECTION = new Connection("https://api.devnet.solana.com", "confirmed");
export const PLATFROM_ACCOUNT = new PublicKey('7UxgfmMiNMbjHxEayn51uRjkeyrMiR4pPXWbo8sFUrsG');
For mainnet deployment, update PROGRAM_ID, CONNECTION, and PLATFROM_ACCOUNT to use mainnet addresses and RPC endpoints.

Next steps

Now that you understand the architecture:
  • Explore the Quickstart guide to interact with the system
  • Review the smart contract code in programs/src/ to understand the game logic
  • Check the API documentation to integrate with external applications
  • Study the WebSocket protocol for building custom clients

Build docs developers (and LLMs) love