From 4fa48d72f1a5d03f41fac462c19d2f4ed4865dba Mon Sep 17 00:00:00 2001 From: Mrwicks00 Date: Sat, 21 Feb 2026 14:52:07 +0100 Subject: [PATCH] feat: implement puzzle bounty contract (closes #75) --- Cargo.toml | 4 +- contracts/puzzle_bounty/Cargo.toml | 14 + contracts/puzzle_bounty/src/lib.rs | 379 ++++++++++++++++++++++++++++ contracts/puzzle_bounty/src/test.rs | 265 +++++++++++++++++++ 4 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 contracts/puzzle_bounty/Cargo.toml create mode 100644 contracts/puzzle_bounty/src/lib.rs create mode 100644 contracts/puzzle_bounty/src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 39f0556..c05b0a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,11 +35,11 @@ members = [ "contracts/prize_pool", "contracts/seasonal_event", "contracts/hint_marketplace", + "contracts/puzzle_bounty", ] [workspace.dependencies] -# Enable test utilities for test builds across workspace -soroban-sdk = { version = "21.0.0", features = ["testutils"] } +soroban-sdk = { version = "21.0.0" } [profile.release] opt-level = "z" diff --git a/contracts/puzzle_bounty/Cargo.toml b/contracts/puzzle_bounty/Cargo.toml new file mode 100644 index 0000000..9fcd1aa --- /dev/null +++ b/contracts/puzzle_bounty/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "puzzle-bounty" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/puzzle_bounty/src/lib.rs b/contracts/puzzle_bounty/src/lib.rs new file mode 100644 index 0000000..8c8a6fa --- /dev/null +++ b/contracts/puzzle_bounty/src/lib.rs @@ -0,0 +1,379 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, Vec, +}; + +// ─── Status ─────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BountyStatus { + Open = 0, + Completed = 1, // all winner slots filled + Expired = 2, + Cancelled = 3, +} + +// ─── Core Bounty Data ───────────────────────────────────────────────────────── + +/// Configuration + state for a puzzle bounty posted by a sponsor. +#[contracttype] +#[derive(Clone, Debug)] +pub struct PuzzleBounty { + pub id: u32, + /// The account that created and funded the bounty. + pub sponsor: Address, + /// Token used for rewards (escrowed in contract). + pub token: Address, + /// Puzzle identifier this bounty is for. + pub puzzle_id: u32, + /// SHA-256 (or any 32-byte) hash of the correct solution. + /// Solvers must supply a matching hash to claim. + pub solution_hash: BytesN<32>, + /// Reward amounts for 1st / 2nd / 3rd place. + pub reward_1st: i128, + pub reward_2nd: i128, + pub reward_3rd: i128, + /// Unix timestamp after which no new claims are accepted. + pub expiration: u64, + pub status: BountyStatus, + /// Number of winners claimed so far (0-3). + pub winner_count: u32, +} + +impl PuzzleBounty { + /// Total tokens locked in escrow when the bounty was created. + pub fn total_reward(&self) -> i128 { + self.reward_1st + self.reward_2nd + self.reward_3rd + } +} + +// ─── Winner / Leaderboard Entry ─────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug)] +pub struct WinnerEntry { + pub rank: u32, + pub solver: Address, + pub reward: i128, + pub claimed_at: u64, +} + +// ─── Storage Keys ───────────────────────────────────────────────────────────── + +#[contracttype] +pub enum DataKey { + Admin, + BountyCount, + Bounty(u32), + Winners(u32), // Vec per bounty +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct PuzzleBountyContract; + +#[contractimpl] +impl PuzzleBountyContract { + // ── Admin / Initialisation ───────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("Already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::BountyCount, &0u32); + } + + // ── Create ──────────────────────────────────────────────────────────── + + /// Sponsor creates a bounty by depositing the full reward pool into escrow. + /// + /// Returns the new bounty ID. + pub fn create_bounty( + env: Env, + sponsor: Address, + token_address: Address, + puzzle_id: u32, + solution_hash: BytesN<32>, + reward_1st: i128, + reward_2nd: i128, + reward_3rd: i128, + duration: u64, + ) -> u32 { + sponsor.require_auth(); + + if reward_1st <= 0 { + panic!("First-place reward must be positive"); + } + if reward_2nd < 0 || reward_3rd < 0 { + panic!("Rewards cannot be negative"); + } + if reward_1st < reward_2nd || reward_2nd < reward_3rd { + panic!("Rewards must be in descending order: 1st >= 2nd >= 3rd"); + } + if duration == 0 { + panic!("Duration must be greater than zero"); + } + + let total = reward_1st + reward_2nd + reward_3rd; + + let mut count: u32 = env + .storage() + .instance() + .get(&DataKey::BountyCount) + .unwrap_or(0); + count += 1; + + let expiration = env.ledger().timestamp() + duration; + + let bounty = PuzzleBounty { + id: count, + sponsor: sponsor.clone(), + token: token_address.clone(), + puzzle_id, + solution_hash, + reward_1st, + reward_2nd, + reward_3rd, + expiration, + status: BountyStatus::Open, + winner_count: 0, + }; + + // Escrow: transfer total reward from sponsor → contract + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&sponsor, &env.current_contract_address(), &total); + + env.storage() + .instance() + .set(&DataKey::Bounty(count), &bounty); + env.storage().instance().set(&DataKey::BountyCount, &count); + + // Initialise empty winners list + let empty: Vec = Vec::new(&env); + env.storage() + .instance() + .set(&DataKey::Winners(count), &empty); + + env.events().publish( + (symbol_short!("pb"), symbol_short!("created")), + (count, sponsor, puzzle_id, total), + ); + + count + } + + // ── Claim ───────────────────────────────────────────────────────────── + + /// Solver submits their solution hash to claim a rank on the bounty. + /// + /// Returns the rank they achieved (1, 2, or 3). + pub fn claim_bounty( + env: Env, + solver: Address, + bounty_id: u32, + solution_hash: BytesN<32>, + ) -> u32 { + solver.require_auth(); + + let mut bounty = Self::get_bounty(env.clone(), bounty_id).expect("Bounty not found"); + + if bounty.status != BountyStatus::Open { + panic!("Bounty is not open"); + } + + if env.ledger().timestamp() > bounty.expiration { + panic!("Bounty has expired"); + } + + if solution_hash != bounty.solution_hash { + panic!("Incorrect solution"); + } + + // Determine rank based on winner_count + let rank = bounty.winner_count + 1; + if rank > 3 { + panic!("All winner slots are filled"); + } + + // Ensure this solver hasn't already claimed + let mut winners: Vec = env + .storage() + .instance() + .get(&DataKey::Winners(bounty_id)) + .unwrap_or(Vec::new(&env)); + + for w in winners.iter() { + if w.solver == solver { + panic!("Solver has already claimed a reward"); + } + } + + let reward = match rank { + 1 => bounty.reward_1st, + 2 => bounty.reward_2nd, + 3 => bounty.reward_3rd, + _ => panic!("Invalid rank"), + }; + + // Pay solver + let token_client = token::Client::new(&env, &bounty.token); + token_client.transfer(&env.current_contract_address(), &solver, &reward); + + // Record winner + winners.push_back(WinnerEntry { + rank, + solver: solver.clone(), + reward, + claimed_at: env.ledger().timestamp(), + }); + env.storage() + .instance() + .set(&DataKey::Winners(bounty_id), &winners); + + bounty.winner_count = rank; + + // Mark completed if 3rd winner just claimed + if rank == 3 { + bounty.status = BountyStatus::Completed; + } + + env.storage() + .instance() + .set(&DataKey::Bounty(bounty_id), &bounty); + + env.events().publish( + (symbol_short!("pb"), symbol_short!("claimed")), + (bounty_id, solver, rank, reward), + ); + + rank + } + + // ── Refund Expired ──────────────────────────────────────────────────── + + /// Anyone can call this after a bounty expires to refund unclaimed rewards + /// back to the sponsor. + pub fn refund_expired(env: Env, bounty_id: u32) { + let mut bounty = Self::get_bounty(env.clone(), bounty_id).expect("Bounty not found"); + + if bounty.status != BountyStatus::Open { + panic!("Bounty is not open"); + } + + if env.ledger().timestamp() <= bounty.expiration { + panic!("Bounty has not expired yet"); + } + + // Calculate unclaimed amount + let winners: Vec = env + .storage() + .instance() + .get(&DataKey::Winners(bounty_id)) + .unwrap_or(Vec::new(&env)); + + let claimed: i128 = winners.iter().map(|w| w.reward).sum(); + let unclaimed = bounty.total_reward() - claimed; + + if unclaimed > 0 { + let token_client = token::Client::new(&env, &bounty.token); + token_client.transfer(&env.current_contract_address(), &bounty.sponsor, &unclaimed); + } + + bounty.status = BountyStatus::Expired; + env.storage() + .instance() + .set(&DataKey::Bounty(bounty_id), &bounty); + + env.events().publish( + (symbol_short!("pb"), symbol_short!("refunded")), + (bounty_id, bounty.sponsor, unclaimed), + ); + } + + // ── Cancel ──────────────────────────────────────────────────────────── + + /// Sponsor cancels an open bounty (only allowed when no winners have + /// claimed yet). Full escrow amount is refunded. + pub fn cancel_bounty(env: Env, sponsor: Address, bounty_id: u32) { + sponsor.require_auth(); + + let mut bounty = Self::get_bounty(env.clone(), bounty_id).expect("Bounty not found"); + + if bounty.sponsor != sponsor { + panic!("Only the sponsor can cancel"); + } + + if bounty.status != BountyStatus::Open { + panic!("Only open bounties can be cancelled"); + } + + if bounty.winner_count > 0 { + panic!("Cannot cancel after winners have claimed"); + } + + let total = bounty.total_reward(); + let token_client = token::Client::new(&env, &bounty.token); + token_client.transfer(&env.current_contract_address(), &sponsor, &total); + + bounty.status = BountyStatus::Cancelled; + env.storage() + .instance() + .set(&DataKey::Bounty(bounty_id), &bounty); + + env.events().publish( + (symbol_short!("pb"), symbol_short!("cancelled")), + (bounty_id, sponsor, total), + ); + } + + // ── Queries ─────────────────────────────────────────────────────────── + + pub fn get_bounty(env: Env, bounty_id: u32) -> Option { + env.storage().instance().get(&DataKey::Bounty(bounty_id)) + } + + pub fn get_bounty_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::BountyCount) + .unwrap_or(0) + } + + /// Returns the winner leaderboard for a specific bounty (up to 3 entries). + pub fn get_leaderboard(env: Env, bounty_id: u32) -> Vec { + env.storage() + .instance() + .get(&DataKey::Winners(bounty_id)) + .unwrap_or(Vec::new(&env)) + } + + /// Returns all open bounties for a given puzzle_id (paginated). + pub fn get_bounties_for_puzzle( + env: Env, + puzzle_id: u32, + offset: u32, + limit: u32, + ) -> Vec { + let count = Self::get_bounty_count(env.clone()); + let mut result: Vec = Vec::new(&env); + let mut seen = 0u32; + + for i in 1..=count { + if let Some(b) = Self::get_bounty(env.clone(), i) { + if b.puzzle_id == puzzle_id && b.status == BountyStatus::Open { + if seen >= offset && result.len() < limit { + result.push_back(b); + } + seen += 1; + } + } + } + result + } +} + +mod test; diff --git a/contracts/puzzle_bounty/src/test.rs b/contracts/puzzle_bounty/src/test.rs new file mode 100644 index 0000000..8f9f11a --- /dev/null +++ b/contracts/puzzle_bounty/src/test.rs @@ -0,0 +1,265 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, BytesN, Env, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn make_hash(env: &Env, seed: u8) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[0] = seed; + BytesN::from_array(env, &bytes) +} + +fn setup(env: &Env) -> (PuzzleBountyContractClient<'_>, Address, token::Client<'_>) { + let admin = Address::generate(env); + let contract_id = env.register_contract(None, PuzzleBountyContract); + let client = PuzzleBountyContractClient::new(env, &contract_id); + client.initialize(&admin); + + let token_admin = Address::generate(env); + let token_id = env.register_stellar_asset_contract(token_admin.clone()); + let token_client = token::Client::new(env, &token_id); + + (client, admin, token_client) +} + +fn mint(env: &Env, token: &token::Client<'_>, to: &Address, amount: i128) { + let asset_client = token::StellarAssetClient::new(env, &token.address); + asset_client.mint(to, &amount); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[test] +fn test_create_bounty_escrows_tokens() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 42); + let id = client.create_bounty(&sponsor, &token.address, &1, &hash, &500, &300, &100, &3600); + + assert_eq!(id, 1); + // Sponsor paid out 900 total; contract holds it + assert_eq!(token.balance(&sponsor), 100); + assert_eq!(token.balance(&client.address), 900); + + let bounty = client.get_bounty(&id).unwrap(); + assert_eq!(bounty.status, BountyStatus::Open); + assert_eq!(bounty.winner_count, 0); +} + +#[test] +fn test_first_solver_gets_top_reward() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let solver = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let correct = make_hash(&env, 7); + let id = client.create_bounty( + &sponsor, + &token.address, + &1, + &correct, + &500, + &300, + &100, + &3600, + ); + + let rank = client.claim_bounty(&solver, &id, &correct); + assert_eq!(rank, 1); + assert_eq!(token.balance(&solver), 500); + + let bounty = client.get_bounty(&id).unwrap(); + assert_eq!(bounty.winner_count, 1); + assert_eq!(bounty.status, BountyStatus::Open); // still open for 2nd/3rd +} + +#[test] +fn test_multi_winner_top3_distribution() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let solver1 = Address::generate(&env); + let solver2 = Address::generate(&env); + let solver3 = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 99); + let id = client.create_bounty(&sponsor, &token.address, &2, &hash, &500, &300, &100, &3600); + + let r1 = client.claim_bounty(&solver1, &id, &hash); + let r2 = client.claim_bounty(&solver2, &id, &hash); + let r3 = client.claim_bounty(&solver3, &id, &hash); + + assert_eq!((r1, r2, r3), (1, 2, 3)); + assert_eq!(token.balance(&solver1), 500); + assert_eq!(token.balance(&solver2), 300); + assert_eq!(token.balance(&solver3), 100); + assert_eq!(token.balance(&client.address), 0); + + let bounty = client.get_bounty(&id).unwrap(); + assert_eq!(bounty.status, BountyStatus::Completed); + + let lb = client.get_leaderboard(&id); + assert_eq!(lb.len(), 3); + assert_eq!(lb.get(0).unwrap().rank, 1); + assert_eq!(lb.get(1).unwrap().rank, 2); + assert_eq!(lb.get(2).unwrap().rank, 3); +} + +#[test] +fn test_refund_expired_unclaimed() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 1); + let id = client.create_bounty( + &sponsor, + &token.address, + &3, + &hash, + &500, + &200, + &100, + &100, // 100 seconds duration + ); + assert_eq!(token.balance(&sponsor), 200); // 1000 - 800 + + // One solver claims 1st place + let solver = Address::generate(&env); + client.claim_bounty(&solver, &id, &hash); + assert_eq!(token.balance(&solver), 500); + + // Fast-forward past expiration + env.ledger().with_mut(|l| l.timestamp = l.timestamp + 200); + + // Refund: 2nd + 3rd (300) should go back to sponsor + client.refund_expired(&id); + // sponsor had 200 remaining, now gets 200+100 = 300 back → total 500 + assert_eq!(token.balance(&sponsor), 200 + 300); + assert_eq!(token.balance(&client.address), 0); + + let bounty = client.get_bounty(&id).unwrap(); + assert_eq!(bounty.status, BountyStatus::Expired); +} + +#[test] +fn test_cancel_open_bounty_refunds_sponsor() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 5); + let id = client.create_bounty(&sponsor, &token.address, &4, &hash, &400, &200, &100, &3600); + assert_eq!(token.balance(&sponsor), 300); + + client.cancel_bounty(&sponsor, &id); + // Full 700 returned + assert_eq!(token.balance(&sponsor), 1000); + assert_eq!(token.balance(&client.address), 0); + + let bounty = client.get_bounty(&id).unwrap(); + assert_eq!(bounty.status, BountyStatus::Cancelled); +} + +#[test] +#[should_panic(expected = "Bounty has expired")] +fn test_cannot_claim_after_expiry() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let solver = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 11); + let id = client.create_bounty(&sponsor, &token.address, &5, &hash, &500, &0, &0, &100); + + // Fast-forward past expiration + env.ledger().with_mut(|l| l.timestamp = l.timestamp + 200); + client.claim_bounty(&solver, &id, &hash); +} + +#[test] +#[should_panic(expected = "Incorrect solution")] +fn test_wrong_solution_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let solver = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let correct = make_hash(&env, 77); + let wrong = make_hash(&env, 88); + let id = client.create_bounty(&sponsor, &token.address, &6, &correct, &500, &0, &0, &3600); + + client.claim_bounty(&solver, &id, &wrong); +} + +#[test] +#[should_panic(expected = "Cannot cancel after winners have claimed")] +fn test_cannot_cancel_after_claim() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let solver = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 33); + let id = client.create_bounty(&sponsor, &token.address, &7, &hash, &500, &300, &100, &3600); + + client.claim_bounty(&solver, &id, &hash); + client.cancel_bounty(&sponsor, &id); +} + +#[test] +fn test_leaderboard_tracks_winners() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, token) = setup(&env); + + let sponsor = Address::generate(&env); + let s1 = Address::generate(&env); + let s2 = Address::generate(&env); + mint(&env, &token, &sponsor, 1000); + + let hash = make_hash(&env, 55); + let id = client.create_bounty(&sponsor, &token.address, &8, &hash, &400, &200, &100, &3600); + + client.claim_bounty(&s1, &id, &hash); + client.claim_bounty(&s2, &id, &hash); + + let lb = client.get_leaderboard(&id); + assert_eq!(lb.len(), 2); + assert_eq!(lb.get(0).unwrap().solver, s1); + assert_eq!(lb.get(0).unwrap().rank, 1); + assert_eq!(lb.get(1).unwrap().solver, s2); + assert_eq!(lb.get(1).unwrap().rank, 2); +}