Last active
March 5, 2025 03:49
-
-
Save jongan69/9d1e248febb3587a2331b1017810ddda to your computer and use it in GitHub Desktop.
Example Solana Rust Lottery Program
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[package] | |
name = "lottery" | |
version = "0.1.0" | |
description = "Created with Anchor" | |
edition = "2021" | |
[lib] | |
crate-type = ["cdylib", "lib"] | |
name = "lottery" | |
[features] | |
default = [] | |
cpi = ["no-entrypoint"] | |
no-entrypoint = [] | |
no-idl = [] | |
no-log-ix-name = [] | |
idl-build = ["anchor-lang/idl-build"] | |
[dependencies] | |
anchor-lang = "0.30.1" | |
anchor-spl = "0.30.1" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Import necessary modules from Anchor and Solana | |
use anchor_lang::prelude::*; | |
use anchor_lang::system_program; | |
// Declare the program ID - this is the unique identifier for this Solana program | |
declare_id!("GUjB8G7uqJ9yhfSfNdWNhyF7gYsrpmJN3dHSEd7J6ZhP"); | |
// Add this after declare_id!() | |
pub const LOTTERY_SEED: &[u8] = b"lottery"; | |
#[program] | |
pub mod lottery { | |
use super::*; | |
// Initialize a new lottery with an entry fee and end time | |
// This function is called once to set up the lottery | |
pub fn initialize(ctx: Context<Initialize>, entry_fee: u64, end_time: i64) -> Result<()> { | |
let lottery = &mut ctx.accounts.lottery; | |
// Store the admin's public key (the person who created the lottery) | |
lottery.admin = ctx.accounts.admin.key(); | |
// Set the cost to enter the lottery | |
lottery.entry_fee = entry_fee; | |
// Set when the lottery will end (as a Unix timestamp) | |
lottery.end_time = end_time; | |
// Initialize ticket count to 0 | |
lottery.total_tickets = 0; | |
// No winner yet, so set to None | |
lottery.winner = None; | |
msg!("Lottery Initialized!"); | |
Ok(()) | |
} | |
// Function to purchase a lottery ticket | |
pub fn buy_ticket(ctx: Context<BuyTicket>) -> Result<()> { | |
require!( | |
Clock::get().unwrap().unix_timestamp <= ctx.accounts.lottery.end_time, | |
LotteryError::LotteryClosed | |
); | |
require!( | |
ctx.accounts.lottery.winner.is_none(), | |
LotteryError::WinnerAlreadySelected | |
); | |
require!( | |
ctx.accounts.lottery.participants.len() < 100, | |
LotteryError::MaxParticipantsReached | |
); | |
let entry_fee = ctx.accounts.lottery.entry_fee; | |
// Create a CPI (Cross-Program Invocation) to transfer SOL from player to lottery account | |
let cpi_context = CpiContext::new( | |
ctx.accounts.system_program.to_account_info(), | |
system_program::Transfer { | |
from: ctx.accounts.player.to_account_info(), | |
to: ctx.accounts.lottery.to_account_info(), | |
}, | |
); | |
// Execute the transfer | |
system_program::transfer(cpi_context, entry_fee)?; | |
// Update the lottery state with new participant | |
let lottery = &mut ctx.accounts.lottery; | |
// Add player's public key to participants list | |
lottery.participants.push(ctx.accounts.player.key()); | |
// Increment total ticket count | |
lottery.total_tickets += 1; | |
msg!("Ticket purchased by: {:?}", ctx.accounts.player.key()); | |
Ok(()) | |
} | |
// Function to select a winner after lottery ends | |
pub fn select_winner(ctx: Context<SelectWinner>) -> Result<()> { | |
let lottery = &mut ctx.accounts.lottery; | |
require!( | |
Clock::get().unwrap().unix_timestamp > lottery.end_time, | |
LotteryError::LotteryNotEnded | |
); | |
require!(lottery.winner.is_none(), LotteryError::WinnerAlreadySelected); | |
require!(!lottery.participants.is_empty(), LotteryError::NoParticipants); | |
// Winner selection happens in callback | |
Ok(()) | |
} | |
// Function for winner to claim their prize (90% of collected SOL) and reset lottery | |
pub fn claim_prize(ctx: Context<ClaimPrize>) -> Result<()> { | |
// Verify winner | |
require!( | |
Some(ctx.accounts.player.key()) == ctx.accounts.lottery.winner, | |
LotteryError::NotWinner | |
); | |
// Calculate prize amount (90% of total collected fees) | |
let total_collected = ctx.accounts.lottery.entry_fee | |
.checked_mul(ctx.accounts.lottery.total_tickets as u64) | |
.ok_or(LotteryError::Overflow)?; | |
let prize_amount = total_collected | |
.checked_mul(90) | |
.ok_or(LotteryError::Overflow)? | |
.checked_div(100) | |
.ok_or(LotteryError::Overflow)?; | |
**ctx.accounts.lottery.to_account_info().try_borrow_mut_lamports()? -= prize_amount; | |
**ctx.accounts.player.to_account_info().try_borrow_mut_lamports()? += prize_amount; | |
// Reset lottery state after transfer | |
let lottery = &mut ctx.accounts.lottery; | |
lottery.total_tickets = 0; | |
lottery.participants.clear(); | |
lottery.winner = None; | |
msg!("Prize of {} lamports claimed by: {:?}", prize_amount, ctx.accounts.player.key()); | |
msg!("Lottery has been reset for the next round"); | |
Ok(()) | |
} | |
} | |
// Account validation struct for Initialize instruction | |
#[derive(Accounts)] | |
pub struct Initialize<'info> { | |
// Create new lottery account, paid for by admin | |
#[account( | |
init, | |
payer = admin, | |
space = 8 + LotteryState::LEN, | |
seeds = [LOTTERY_SEED], | |
bump | |
)] | |
pub lottery: Account<'info, LotteryState>, | |
// Admin must sign this transaction and pay for account creation | |
#[account(mut)] | |
pub admin: Signer<'info>, | |
// Required for creating new accounts | |
pub system_program: Program<'info, System>, | |
} | |
// Account validation struct for BuyTicket instruction | |
#[derive(Accounts)] | |
pub struct BuyTicket<'info> { | |
// Lottery account to be modified | |
#[account( | |
mut, | |
seeds = [LOTTERY_SEED], | |
bump | |
)] | |
pub lottery: Account<'info, LotteryState>, | |
// Player must sign transaction and pay entry fee | |
#[account(mut)] | |
pub player: Signer<'info>, | |
// Required for SOL transfers | |
pub system_program: Program<'info, System>, | |
} | |
// Account validation struct for SelectWinner instruction | |
#[derive(Accounts)] | |
pub struct SelectWinner<'info> { | |
// Lottery account to be modified | |
#[account( | |
mut, | |
seeds = [LOTTERY_SEED], | |
bump | |
)] | |
pub lottery: Account<'info, LotteryState> | |
} | |
// Account validation struct for ClaimPrize instruction | |
#[derive(Accounts)] | |
pub struct ClaimPrize<'info> { | |
// Lottery account to verify winner and transfer funds from | |
#[account( | |
mut, | |
seeds = [LOTTERY_SEED], | |
bump, | |
constraint = lottery.winner.is_some() @ LotteryError::NoWinnerSelected, | |
constraint = lottery.winner.unwrap() == player.key() @ LotteryError::NotWinner, | |
)] | |
pub lottery: Account<'info, LotteryState>, | |
// Winner must sign to claim prize | |
#[account(mut)] | |
pub player: Signer<'info>, | |
// Required for SOL transfers | |
pub system_program: Program<'info, System>, | |
} | |
// Main state account storing all lottery data | |
#[account] | |
pub struct LotteryState { | |
// Admin's public key | |
pub admin: Pubkey, | |
// Cost to enter lottery | |
pub entry_fee: u64, | |
// Number of tickets sold | |
pub total_tickets: u32, | |
// List of participant public keys | |
pub participants: Vec<Pubkey>, | |
// When lottery ends (Unix timestamp) | |
pub end_time: i64, | |
// Winner's public key (if selected) | |
pub winner: Option<Pubkey>, | |
} | |
// Calculate required space for LotteryState account | |
impl LotteryState { | |
// 32 (pubkey) + 8 (u64) + 4 (u32) + (32 * 100) (vec of pubkeys) + 8 (i64) + 1 (option) | |
const LEN: usize = 32 + 8 + 4 + (32 * 100) + 8 + 1; // Adjust 100 based on max participants. | |
} | |
// Custom error codes for the program | |
#[error_code] | |
pub enum LotteryError { | |
#[msg("The lottery has already ended.")] | |
LotteryClosed, | |
#[msg("The lottery has not ended yet.")] | |
LotteryNotEnded, | |
#[msg("A winner has already been selected.")] | |
WinnerAlreadySelected, | |
#[msg("You are not the winner.")] | |
NotWinner, | |
#[msg("Arithmetic overflow occurred.")] | |
Overflow, | |
#[msg("No participants in the lottery.")] | |
NoParticipants, | |
#[msg("Maximum participants reached.")] | |
MaxParticipantsReached, | |
#[msg("No winner selected.")] | |
NoWinnerSelected, | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as anchor from "@coral-xyz/anchor"; | |
import { Program } from "@coral-xyz/anchor"; | |
import { Lottery } from "../target/types/lottery"; | |
import { Keypair, LAMPORTS_PER_SOL, SystemProgram } from "@solana/web3.js"; | |
import { assert } from "chai"; | |
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); | |
describe("lottery", () => { | |
// Configure the client to use the local cluster | |
anchor.setProvider(anchor.AnchorProvider.env()); | |
const program = anchor.workspace.Lottery as Program<Lottery>; | |
const provider = anchor.getProvider(); | |
// Use the default wallet as admin | |
const admin = { | |
publicKey: (provider as anchor.AnchorProvider).wallet.publicKey, | |
signTransaction: (provider as anchor.AnchorProvider).wallet.signTransaction.bind((provider as anchor.AnchorProvider).wallet) | |
}; | |
// Test accounts - we'll transfer SOL to these from admin | |
const player1 = Keypair.generate(); | |
const player2 = Keypair.generate(); | |
const player3 = Keypair.generate(); | |
// Test state | |
const entryFee = new anchor.BN(0.1 * LAMPORTS_PER_SOL); // 0.1 SOL | |
const [lotteryPDA] = anchor.web3.PublicKey.findProgramAddressSync( | |
[Buffer.from("lottery")], | |
program.programId | |
); | |
// Helper function to get confirmation strategy | |
const getConfirmationStrategy = async (signature: string) => { | |
const { blockhash, lastValidBlockHeight } = await provider.connection.getLatestBlockhash(); | |
return { | |
signature, | |
blockhash, | |
lastValidBlockHeight, | |
}; | |
}; | |
// Update transferSol function | |
const transferSol = async (from: { publicKey: anchor.web3.PublicKey, signTransaction: (tx: anchor.web3.Transaction) => Promise<anchor.web3.Transaction> }, | |
to: Keypair, | |
amount: number) => { | |
const tx = new anchor.web3.Transaction(); | |
const latestBlockhash = await provider.connection.getLatestBlockhash(); | |
tx.recentBlockhash = latestBlockhash.blockhash; | |
tx.feePayer = from.publicKey; | |
tx.add( | |
SystemProgram.transfer({ | |
fromPubkey: from.publicKey, | |
toPubkey: to.publicKey, | |
lamports: amount, | |
}) | |
); | |
const signedTx = await from.signTransaction(tx); | |
const txid = await provider.connection.sendRawTransaction(signedTx.serialize()); | |
await provider.connection.confirmTransaction(await getConfirmationStrategy(txid)); | |
}; | |
beforeEach(async () => { | |
try { | |
// Only airdrop if admin balance is low | |
const adminBalance = await provider.connection.getBalance(admin.publicKey); | |
if (adminBalance < LAMPORTS_PER_SOL) { | |
await provider.connection.requestAirdrop(admin.publicKey, 2 * LAMPORTS_PER_SOL); | |
await sleep(1000); | |
} | |
// Transfer smaller amounts to test accounts | |
const FUND_AMOUNT = 0.2 * LAMPORTS_PER_SOL; | |
for (const account of [player1, player2, player3]) { | |
const balance = await provider.connection.getBalance(account.publicKey); | |
if (balance < FUND_AMOUNT) { | |
await transferSol(admin, account, FUND_AMOUNT); | |
await sleep(500); | |
} | |
} | |
} catch (error) { | |
console.error("Setup error:", error); | |
throw error; | |
} | |
}); | |
it("Initializes the lottery", async () => { | |
// Clear any existing state | |
try { | |
await program.account.lotteryState.fetch(lotteryPDA); | |
// If we get here, the account exists and we should skip initialization | |
return; | |
} catch { | |
// Account doesn't exist, proceed with initialization | |
const now = Math.floor(Date.now() / 1000); | |
const endTime = new anchor.BN(now + 10); | |
const tx = await program.methods | |
.initialize(entryFee, endTime) | |
.accounts({ | |
lottery: lotteryPDA, | |
admin: admin.publicKey, | |
systemProgram: SystemProgram.programId, | |
}) | |
.signers([]) | |
.rpc(); | |
await provider.connection.confirmTransaction(await getConfirmationStrategy(tx)); | |
const lotteryAccount = await program.account.lotteryState.fetch(lotteryPDA); | |
assert.equal(lotteryAccount.admin.toBase58(), admin.publicKey.toBase58()); | |
assert.equal(lotteryAccount.entryFee.toString(), entryFee.toString()); | |
assert.equal(lotteryAccount.totalTickets, 0); | |
assert.equal(lotteryAccount.participants.length, 0); | |
} | |
}); | |
it("Allows players to buy tickets", async () => { | |
// Clear participants array first | |
const lotteryBefore = await program.account.lotteryState.fetch(lotteryPDA); | |
console.log("\nCurrent lottery state:"); | |
console.log("Total tickets:", lotteryBefore.totalTickets); | |
console.log("Participants:", lotteryBefore.participants.map(p => p.toBase58())); | |
if (lotteryBefore.totalTickets > 0) { | |
return; // Skip if tickets already purchased | |
} | |
// Get initial balances | |
const player1InitialBalance = await provider.connection.getBalance(player1.publicKey); | |
const player2InitialBalance = await provider.connection.getBalance(player2.publicKey); | |
const lotteryInitialBalance = await provider.connection.getBalance(lotteryPDA); | |
console.log("\nInitial Balances:"); | |
console.log(Player 1: ${player1InitialBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Player 2: ${player2InitialBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Lottery: ${lotteryInitialBalance / LAMPORTS_PER_SOL} SOL); | |
// Player 1 buys a ticket | |
const tx1 = await program.methods | |
.buyTicket() | |
.accounts({ | |
lottery: lotteryPDA, | |
player: player1.publicKey, | |
systemProgram: SystemProgram.programId, | |
}) | |
.signers([player1]) | |
.rpc(); | |
await provider.connection.confirmTransaction(await getConfirmationStrategy(tx1)); | |
const player1BalanceAfterBuy = await provider.connection.getBalance(player1.publicKey); | |
console.log(\nPlayer 1 balance after buying ticket: ${player1BalanceAfterBuy / LAMPORTS_PER_SOL} SOL); | |
console.log(Cost of ticket: ${(player1InitialBalance - player1BalanceAfterBuy) / LAMPORTS_PER_SOL} SOL); | |
// Player 2 buys a ticket | |
const tx2 = await program.methods | |
.buyTicket() | |
.accounts({ | |
lottery: lotteryPDA, | |
player: player2.publicKey, | |
systemProgram: SystemProgram.programId, | |
}) | |
.signers([player2]) | |
.rpc(); | |
await provider.connection.confirmTransaction(await getConfirmationStrategy(tx2)); | |
const player2BalanceAfterBuy = await provider.connection.getBalance(player2.publicKey); | |
console.log(Player 2 balance after buying ticket: ${player2BalanceAfterBuy / LAMPORTS_PER_SOL} SOL); | |
console.log(Cost of ticket: ${(player2InitialBalance - player2BalanceAfterBuy) / LAMPORTS_PER_SOL} SOL); | |
// Verify lottery state | |
const lotteryAccount = await program.account.lotteryState.fetch(lotteryPDA); | |
const lotteryFinalBalance = await provider.connection.getBalance(lotteryPDA); | |
console.log(\nLottery final balance: ${lotteryFinalBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Total collected: ${(lotteryFinalBalance - lotteryInitialBalance) / LAMPORTS_PER_SOL} SOL); | |
assert.equal(lotteryAccount.totalTickets, 2); | |
assert.equal(lotteryAccount.participants.length, 2); | |
}); | |
it("Selects a winner after the end time", async () => { | |
const lotteryBefore = await program.account.lotteryState.fetch(lotteryPDA); | |
if (lotteryBefore.winner !== null) { | |
return; // Skip if winner already selected | |
} | |
// Wait for the lottery to end (10 seconds + 2 seconds buffer) | |
await new Promise((resolve) => setTimeout(resolve, 12000)); | |
const tx = await program.methods | |
.selectWinner() | |
.accounts({ | |
lottery: lotteryPDA, | |
}) | |
.rpc(); | |
await provider.connection.confirmTransaction(await getConfirmationStrategy(tx)); | |
const lotteryAccount = await program.account.lotteryState.fetch(lotteryPDA); | |
assert.isNotNull(lotteryAccount.winner); | |
}); | |
it("Allows winner to claim prize", async () => { | |
const lotteryAccount = await program.account.lotteryState.fetch(lotteryPDA); | |
const winner = lotteryAccount.winner; | |
if (!winner) { | |
console.log("No winner selected yet, skipping claim test"); | |
return; | |
} | |
// Debug prints | |
console.log("\nParticipants in lottery:", lotteryAccount.participants.map(p => p.toBase58())); | |
console.log("Selected winner:", winner.toBase58()); | |
console.log("Player 1:", player1.publicKey.toBase58()); | |
console.log("Player 2:", player2.publicKey.toBase58()); | |
// Find which player is the winner | |
let winningPlayer; | |
if (winner.equals(player1.publicKey)) { | |
winningPlayer = player1; | |
console.log("Winner is Player 1"); | |
} else if (winner.equals(player2.publicKey)) { | |
winningPlayer = player2; | |
console.log("Winner is Player 2"); | |
} else { | |
console.log("Winner public key matches neither player!"); | |
console.log("Winner:", winner.toBase58()); | |
console.log("Player1:", player1.publicKey.toBase58()); | |
console.log("Player2:", player2.publicKey.toBase58()); | |
throw new Error("Winner is neither player1 nor player2!"); | |
} | |
// Calculate expected prize (90% of total collected fees) | |
const totalCollected = entryFee.mul(new anchor.BN(lotteryAccount.totalTickets)); | |
const expectedPrize = totalCollected.mul(new anchor.BN(90)).div(new anchor.BN(100)); | |
console.log(\nTotal collected: ${totalCollected.toNumber() / LAMPORTS_PER_SOL} SOL); | |
console.log(Expected prize: ${expectedPrize.toNumber() / LAMPORTS_PER_SOL} SOL); | |
// Get initial balances | |
const winnerInitialBalance = await provider.connection.getBalance(winningPlayer.publicKey); | |
const lotteryInitialBalance = await provider.connection.getBalance(lotteryPDA); | |
console.log(\nInitial balances:); | |
console.log(Winner: ${winnerInitialBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Lottery: ${lotteryInitialBalance / LAMPORTS_PER_SOL} SOL); | |
// Try with the actual winner | |
console.log("\nAttempting to claim with winning player..."); | |
await program.methods | |
.claimPrize() | |
.accounts({ | |
lottery: lotteryPDA, | |
player: winningPlayer.publicKey, | |
systemProgram: SystemProgram.programId, | |
}) | |
.signers([winningPlayer]) | |
.rpc(); | |
// Verify the prize transfer | |
const winnerFinalBalance = await provider.connection.getBalance(winningPlayer.publicKey); | |
const lotteryFinalBalance = await provider.connection.getBalance(lotteryPDA); | |
console.log(\nFinal balances:); | |
console.log(Winner: ${winnerFinalBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Lottery: ${lotteryFinalBalance / LAMPORTS_PER_SOL} SOL); | |
console.log(Prize claimed: ${(winnerFinalBalance - winnerInitialBalance) / LAMPORTS_PER_SOL} SOL); | |
assert.approximately( | |
winnerFinalBalance - winnerInitialBalance, | |
expectedPrize.toNumber(), | |
1000000 // Allow for small difference due to transaction fees | |
); | |
assert.approximately( | |
lotteryInitialBalance - lotteryFinalBalance, | |
expectedPrize.toNumber(), | |
1000000 | |
); | |
// After verifying prize transfer, check that lottery was reset | |
const lotteryAfterReset = await program.account.lotteryState.fetch(lotteryPDA); | |
console.log("\nVerifying lottery reset:"); | |
console.log("Total tickets:", lotteryAfterReset.totalTickets); | |
console.log("Participants:", lotteryAfterReset.participants.length); | |
console.log("Winner:", lotteryAfterReset.winner); | |
assert.equal(lotteryAfterReset.totalTickets, 0, "Total tickets should be reset to 0"); | |
assert.equal(lotteryAfterReset.participants.length, 0, "Participants should be cleared"); | |
assert.isNull(lotteryAfterReset.winner, "Winner should be reset to null"); | |
// Try with the losing player (should fail) | |
const losingPlayer = winningPlayer.publicKey.equals(player1.publicKey) ? player2 : player1; | |
console.log("\nAttempting to claim with losing player (should fail)..."); | |
try { | |
await program.methods | |
.claimPrize() | |
.accounts({ | |
lottery: lotteryPDA, | |
player: losingPlayer.publicKey, | |
systemProgram: SystemProgram.programId, | |
}) | |
.signers([losingPlayer]) | |
.rpc(); | |
assert.fail("Non-winner was able to claim prize"); | |
} catch (error) { | |
console.log("Correctly failed for non-winner"); | |
} | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment