CYBERVISION
    [ CONSULTING ]
    AboutServicesProjectsBlogContact
    Book a Call
    CYBERVISION
    AboutServicesProjectsBlogContact

    © 2026 Cyber Vision Consulting

    Back to Insights
    SolanaRustAnchorWeb3

    Build a Coinflip dApp with Anchor & Rust on Solana

    Learn how to build a provably fair on-chain coinflip game on Solana using Anchor and Rust — from program logic to client integration.

    C

    Cyber Vision

    February 28, 2026

    Why Build a Coinflip on Solana?

    A coinflip is the "hello world" of on-chain gaming. It forces you to grapple with randomness, escrow patterns, and PDA-based state management — skills that transfer to every Solana program you will ever write. Plus, Solana's sub-second finality makes the UX feel instant compared to EVM-based alternatives.

    In this tutorial you will build a complete coinflip program with Anchor 0.30+ and a minimal TypeScript client that calls it.

    Prerequisites

    • Rust toolchain (rustup — stable channel)
    • Solana CLI v1.18+
    • Anchor CLI v0.30+
    • Node.js 18+ and yarn or npm
    # Verify your setup
    solana --version
    anchor --version
    rustc --version

    1. Scaffold the Project

    anchor init coinflip
    cd coinflip

    Anchor generates:

    • programs/coinflip/src/lib.rs — your on-chain program
    • tests/coinflip.ts — Mocha-based integration tests
    • Anchor.toml — config (cluster, program ID, wallet)

    2. Define the State Account

    Every coinflip round needs persistent state. Create a PDA account that stores the wager, the players, and the result.

    use anchor_lang::prelude::*;
     
    declare_id!("YOUR_PROGRAM_ID_HERE");
     
    #[program]
    pub mod coinflip {
        use super::*;
     
        pub fn create_game(ctx: Context<CreateGame>, wager: u64) -> Result<()> {
            let game = &mut ctx.accounts.game;
            game.player_one = ctx.accounts.player.key();
            game.wager = wager;
            game.state = GameState::Waiting;
            game.bump = ctx.bumps.game;
     
            // Transfer wager from player to game vault
            anchor_lang::system_program::transfer(
                CpiContext::new(
                    ctx.accounts.system_program.to_account_info(),
                    anchor_lang::system_program::Transfer {
                        from: ctx.accounts.player.to_account_info(),
                        to: ctx.accounts.vault.to_account_info(),
                    },
                ),
                wager,
            )?;
     
            Ok(())
        }
     
        pub fn join_game(ctx: Context<JoinGame>) -> Result<()> {
            let game = &mut ctx.accounts.game;
            require!(game.state == GameState::Waiting, CoinflipError::GameNotWaiting);
            game.player_two = ctx.accounts.player.key();
            game.state = GameState::Active;
     
            // Player two deposits their wager
            anchor_lang::system_program::transfer(
                CpiContext::new(
                    ctx.accounts.system_program.to_account_info(),
                    anchor_lang::system_program::Transfer {
                        from: ctx.accounts.player.to_account_info(),
                        to: ctx.accounts.vault.to_account_info(),
                    },
                ),
                game.wager,
            )?;
     
            Ok(())
        }
     
        pub fn resolve_game(ctx: Context<ResolveGame>) -> Result<()> {
            let game = &mut ctx.accounts.game;
            require!(game.state == GameState::Active, CoinflipError::GameNotActive);
     
            // Pseudo-random coin flip using recent slot hashes
            let clock = Clock::get()?;
            let flip = clock.slot % 2;
     
            let winner = if flip == 0 {
                game.player_one
            } else {
                game.player_two
            };
     
            game.winner = winner;
            game.state = GameState::Resolved;
     
            // Transfer the full pot to the winner
            let payout = game.wager * 2;
            **ctx.accounts.vault.to_account_info().try_borrow_mut_lamports()? -= payout;
            **ctx.accounts.winner_account.to_account_info().try_borrow_mut_lamports()? += payout;
     
            Ok(())
        }
    }
    ⚠️

    Using Clock::slot for randomness is not secure for production. Validators can manipulate slot ordering. Use a Verifiable Random Function (VRF) such as Switchboard VRF for real money games.

    3. Accounts and State Definitions

    #[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq)]
    pub enum GameState {
        Waiting,
        Active,
        Resolved,
    }
     
    #[account]
    pub struct Game {
        pub player_one: Pubkey,
        pub player_two: Pubkey,
        pub winner: Pubkey,
        pub wager: u64,
        pub state: GameState,
        pub bump: u8,
    }
     
    impl Game {
        pub const SIZE: usize = 8 + 32 + 32 + 32 + 8 + 1 + 1;
    }
     
    #[derive(Accounts)]
    pub struct CreateGame<'info> {
        #[account(
            init,
            payer = player,
            space = Game::SIZE,
            seeds = [b"game", player.key().as_ref()],
            bump
        )]
        pub game: Account<'info, Game>,
        /// CHECK: PDA vault holds the wagers
        #[account(
            mut,
            seeds = [b"vault", game.key().as_ref()],
            bump
        )]
        pub vault: UncheckedAccount<'info>,
        #[account(mut)]
        pub player: Signer<'info>,
        pub system_program: Program<'info, System>,
    }
     
    #[derive(Accounts)]
    pub struct JoinGame<'info> {
        #[account(mut)]
        pub game: Account<'info, Game>,
        /// CHECK: PDA vault
        #[account(mut)]
        pub vault: UncheckedAccount<'info>,
        #[account(mut)]
        pub player: Signer<'info>,
        pub system_program: Program<'info, System>,
    }
     
    #[derive(Accounts)]
    pub struct ResolveGame<'info> {
        #[account(mut)]
        pub game: Account<'info, Game>,
        /// CHECK: PDA vault
        #[account(mut)]
        pub vault: UncheckedAccount<'info>,
        /// CHECK: Winner receives the payout
        #[account(mut)]
        pub winner_account: UncheckedAccount<'info>,
    }
     
    #[error_code]
    pub enum CoinflipError {
        #[msg("Game is not in Waiting state")]
        GameNotWaiting,
        #[msg("Game is not in Active state")]
        GameNotActive,
    }

    4. Build and Deploy

    anchor build
    anchor keys list         # Copy the new program ID
    # Update declare_id!() in lib.rs and Anchor.toml
    anchor deploy --provider.cluster devnet

    5. TypeScript Client Integration

    import * as anchor from "@coral-xyz/anchor";
    import { Program } from "@coral-xyz/anchor";
    import { Coinflip } from "../target/types/coinflip";
    import { PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/web3.js";
     
    const provider = anchor.AnchorProvider.env();
    anchor.setProvider(provider);
    const program = anchor.workspace.Coinflip as Program<Coinflip>;
     
    async function play() {
      const player = provider.wallet.publicKey;
     
      // Derive the game PDA
      const [gamePda] = PublicKey.findProgramAddressSync(
        [Buffer.from("game"), player.toBuffer()],
        program.programId
      );
     
      // Derive the vault PDA
      const [vaultPda] = PublicKey.findProgramAddressSync(
        [Buffer.from("vault"), gamePda.toBuffer()],
        program.programId
      );
     
      const wager = new anchor.BN(0.1 * LAMPORTS_PER_SOL);
     
      // Create a new game
      await program.methods
        .createGame(wager)
        .accounts({
          game: gamePda,
          vault: vaultPda,
          player: player,
          systemProgram: SystemProgram.programId,
        })
        .rpc();
     
      console.log("Game created at:", gamePda.toBase58());
    }
     
    play();

    6. Writing Integration Tests

    import { expect } from "chai";
     
    describe("coinflip", () => {
      it("creates a game with the correct wager", async () => {
        // ... setup from above ...
        await program.methods.createGame(wager).accounts({ /* ... */ }).rpc();
     
        const gameAccount = await program.account.game.fetch(gamePda);
        expect(gameAccount.wager.toNumber()).to.equal(wager.toNumber());
        expect(gameAccount.state).to.deep.equal({ waiting: {} });
      });
     
      it("lets a second player join and resolves the flip", async () => {
        // join and resolve ...
        const gameAccount = await program.account.game.fetch(gamePda);
        expect(gameAccount.state).to.deep.equal({ resolved: {} });
        expect(gameAccount.winner.toBase58()).to.not.be.empty;
      });
    });
    anchor test

    Production Considerations

    ConcernSolution
    RandomnessReplace slot-based flip with Switchboard VRF or Chainlink VRF
    Front-runningUse a commit-reveal scheme so players cannot see the result before submitting
    Fee modelAdd a small protocol fee deducted before payout
    Account cleanupClose the game PDA after resolution to reclaim rent
    Multi-playerExtend to N-player lottery by generalizing the Game account

    Wrapping Up

    You now have a working coinflip program on Solana Devnet. The pattern — PDA state, escrow vault, CPI transfers, pseudo-random resolution — is the foundation for on-chain games, DeFi vaults, and any program that needs trustless two-party settlements.

    Check out the Anchor documentation and the Solana Cookbook for deeper dives into advanced patterns like VRF integration and cross-program invocations.

    Enjoyed this article? Explore more engineering insights.More articles

    Comments

    Leave a comment