diff --git a/Cargo.toml b/Cargo.toml index 58e0be2..5e7eaa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,8 @@ members = [ "contracts/tournament", "contracts/bounty", "contracts/vesting", "contracts/bounty", - "contracts/insurance", + "contracts/insurance", "contracts/lottery", + "contracts/lottery", ] [workspace.dependencies] diff --git a/contracts/lottery/Cargo.toml b/contracts/lottery/Cargo.toml new file mode 100644 index 0000000..91b31c9 --- /dev/null +++ b/contracts/lottery/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lottery" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "25.0.1" \ No newline at end of file diff --git a/contracts/lottery/src/lib.rs b/contracts/lottery/src/lib.rs new file mode 100644 index 0000000..22d60fe --- /dev/null +++ b/contracts/lottery/src/lib.rs @@ -0,0 +1,203 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, + Env, Address, Vec, Bytes, +}; + +#[contracttype] +#[derive(Clone, PartialEq)] +pub enum RoundStatus { + Open, + Drawing, + Completed, + Cancelled, +} + +#[contracttype] +#[derive(Clone)] +pub struct LotteryRound { + pub id: u32, + pub ticket_price: i128, + pub prize_pool: i128, + pub start_time: u64, + pub end_time: u64, + pub winner: Option
, + pub status: RoundStatus, + pub claimed: bool, // 👈 prevents double-claim +} + +#[contracttype] +pub enum DataKey { + Owner, + Token, + CurrentRound, + Round(u32), + Players(u32), +} + +#[contract] +pub struct LotteryContract; + +#[contractimpl] +impl LotteryContract { + pub fn init(env: Env, owner: Address, token: Address) { + owner.require_auth(); + + env.storage().instance().set(&DataKey::Owner, &owner); + env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::CurrentRound, &0u32); + } +} + +pub fn start_round(env: Env, ticket_price: i128, duration: u64) { + let owner: Address = env.storage().instance().get(&DataKey::Owner).unwrap(); + owner.require_auth(); + + let mut round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + round_id += 1; + + let now = env.ledger().timestamp(); + + let round = LotteryRound { + id: round_id, + ticket_price, + prize_pool: 0, + start_time: now, + end_time: now + duration, + winner: None, + status: RoundStatus::Open, + claimed: false, + }; + + env.storage().persistent().set(&DataKey::Round(round_id), &round); + env.storage().persistent().set(&DataKey::Players(round_id), &Vec::
::new(&env)); + env.storage().instance().set(&DataKey::CurrentRound, &round_id); +} + +pub fn buy_ticket(env: Env, user: Address) { + user.require_auth(); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let mut round: LotteryRound = + env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + if round.status != RoundStatus::Open { + panic!("Round not open"); + } + + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let client = soroban_sdk::token::Client::new(&env, &token); + + client.transfer(&user, &env.current_contract_address(), &round.ticket_price); + round.prize_pool += round.ticket_price; + + let mut players: Vec
= + env.storage().persistent().get(&DataKey::Players(round_id)).unwrap(); + + players.push_back(user); + + env.storage().persistent().set(&DataKey::Players(round_id), &players); + env.storage().persistent().set(&DataKey::Round(round_id), &round); +} + +fn generate_random(env: &Env) -> u64 { + let seed = env.ledger().sequence(); + let hash = env.crypto().sha256(&Bytes::from_array(env, &seed.to_be_bytes())); + let bytes = hash.to_array(); + u64::from_be_bytes(bytes[..8].try_into().unwrap()) +} + +pub fn draw_winner(env: Env) { + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let mut round: LotteryRound = + env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + if round.status != RoundStatus::Open { + panic!("Winner already drawn"); + } + + if env.ledger().timestamp() < round.end_time { + panic!("Round still active"); + } + + let players: Vec
= + env.storage().persistent().get(&DataKey::Players(round_id)).unwrap(); + + if players.len() == 0 { + panic!("No players"); + } + + let rand = generate_random(&env); + let index = (rand % players.len() as u64) as u32; + + round.winner = Some(players.get(index).unwrap()); + round.status = RoundStatus::Completed; + + env.storage().persistent().set(&DataKey::Round(round_id), &round); +} + +pub fn claim_prize(env: Env, user: Address, round_id: u32) { + user.require_auth(); + + let mut round: LotteryRound = + env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + if round.status != RoundStatus::Completed { + panic!("Round not completed"); + } + + if round.claimed { + panic!("Prize already claimed"); + } + + if round.winner != Some(user.clone()) { + panic!("Not winner"); + } + + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let client = soroban_sdk::token::Client::new(&env, &token); + + let amount = round.prize_pool; + round.prize_pool = 0; + round.claimed = true; + + client.transfer(&env.current_contract_address(), &user, &amount); + + env.storage().persistent().set(&DataKey::Round(round_id), &round); +} + +pub fn cancel_round(env: Env) { + let owner: Address = env.storage().instance().get(&DataKey::Owner).unwrap(); + owner.require_auth(); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let mut round: LotteryRound = + env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + round.status = RoundStatus::Cancelled; + env.storage().persistent().set(&DataKey::Round(round_id), &round); +} + +pub fn refund(env: Env, round_id: u32, user: Address) { + user.require_auth(); + + let round = get_round(env.clone(), round_id); + + if round.status != RoundStatus::Cancelled { + panic!("Round not cancelled"); + } + + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let client = soroban_sdk::token::Client::new(&env, &token); + + client.transfer( + &env.current_contract_address(), + &user, + &round.ticket_price, + ); +} + +pub fn get_round(env: Env, round_id: u32) -> LotteryRound { + env.storage().persistent().get(&DataKey::Round(round_id)).unwrap() +} \ No newline at end of file diff --git a/contracts/lottery/src/test.rs b/contracts/lottery/src/test.rs new file mode 100644 index 0000000..12afead --- /dev/null +++ b/contracts/lottery/src/test.rs @@ -0,0 +1,178 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient}, + Address, Env, +}; + +fn setup_env() -> Env { + let env = Env::default(); + env.ledger().with_mut(|l| { + l.timestamp = 100; + l.sequence_number = 1; + }); + env +} + +fn setup_token(env: &Env, admin: &Address) -> (Address, TokenClient) { + let token_id = env.register_stellar_asset_contract(admin.clone()); + let token = TokenClient::new(env, &token_id); + (token_id, token) +} + +fn setup_lottery(env: &Env, owner: &Address, token: &Address) { + LotteryContract::init(env.clone(), owner.clone(), token.clone()); +} + +#[test] +fn test_init() { + let env = setup_env(); + let owner = Address::generate(&env); + let (token, _) = setup_token(&env, &owner); + + setup_lottery(&env, &owner, &token); + + let stored_owner: Address = env.storage().instance().get(&DataKey::Owner).unwrap(); + assert_eq!(stored_owner, owner); +} + +#[test] +fn test_start_round() { + let env = setup_env(); + let owner = Address::generate(&env); + let (token, _) = setup_token(&env, &owner); + + setup_lottery(&env, &owner, &token); + + start_round(env.clone(), 100, 50); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let round: LotteryRound = env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + assert_eq!(round.ticket_price, 100); + assert_eq!(round.status, RoundStatus::Open); +} + +#[test] +fn test_buy_ticket() { + let env = setup_env(); + let owner = Address::generate(&env); + let user = Address::generate(&env); + + let (token_id, token) = setup_token(&env, &owner); + setup_lottery(&env, &owner, &token_id); + + start_round(env.clone(), 100, 50); + + token.mint(&user, &100); + token.approve( + &user, + &env.current_contract_address(), + &100, + &env.ledger().sequence(), + ); + + buy_ticket(env.clone(), user.clone()); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let players: Vec
= + env.storage().persistent().get(&DataKey::Players(round_id)).unwrap(); + + assert_eq!(players.len(), 1); +} + +#[test] +#[should_panic(expected = "Round not open")] +fn test_buy_ticket_closed_round() { + let env = setup_env(); + let owner = Address::generate(&env); + let user = Address::generate(&env); + + let (token_id, _) = setup_token(&env, &owner); + setup_lottery(&env, &owner, &token_id); + + start_round(env.clone(), 100, 0); + + env.ledger().with_mut(|l| l.timestamp += 100); + + buy_ticket(env.clone(), user); +} + +#[test] +fn test_draw_winner() { + let env = setup_env(); + let owner = Address::generate(&env); + let user = Address::generate(&env); + + let (token_id, token) = setup_token(&env, &owner); + setup_lottery(&env, &owner, &token_id); + + start_round(env.clone(), 100, 10); + + token.mint(&user, &100); + token.approve( + &user, + &env.current_contract_address(), + &100, + &env.ledger().sequence(), + ); + + buy_ticket(env.clone(), user.clone()); + + env.ledger().with_mut(|l| l.timestamp += 20); + + draw_winner(env.clone()); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let round: LotteryRound = env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + assert_eq!(round.status, RoundStatus::Completed); + assert!(round.winner.is_some()); +} + +#[test] +#[should_panic(expected = "Not winner")] +fn test_claim_prize_not_winner() { + let env = setup_env(); + let owner = Address::generate(&env); + let user = Address::generate(&env); + + let (token_id, _) = setup_token(&env, &owner); + setup_lottery(&env, &owner, &token_id); + + start_round(env.clone(), 100, 0); + env.ledger().with_mut(|l| l.timestamp += 1); + + draw_winner(env.clone()); + + claim_prize(env.clone(), user, 1); +} + + +#[test] +fn test_cancel_round() { + let env = setup_env(); + let owner = Address::generate(&env); + let (token_id, _) = setup_token(&env, &owner); + + setup_lottery(&env, &owner, &token_id); + start_round(env.clone(), 100, 50); + + cancel_round(env.clone()); + + let round_id: u32 = env.storage().instance().get(&DataKey::CurrentRound).unwrap(); + let round: LotteryRound = env.storage().persistent().get(&DataKey::Round(round_id)).unwrap(); + + assert_eq!(round.status, RoundStatus::Cancelled); +} + +#[test] +#[should_panic] +fn test_refund_unimplemented() { + let env = setup_env(); + let user = Address::generate(&env); + + refund(env, 1, user); +} \ No newline at end of file