diff --git a/contracts/bounty/src/lib.rs b/contracts/bounty/src/lib.rs index adacfa7..9264256 100644 --- a/contracts/bounty/src/lib.rs +++ b/contracts/bounty/src/lib.rs @@ -1,37 +1,113 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env, token, symbol_short, Symbol, Map}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, token, Address, Env, Map, Symbol, +}; #[contract] pub struct BountyContract; +#[contracttype] +#[derive(Clone)] +pub struct BountyEscrow { + pub token: Address, + pub amount: i128, +} + #[contractimpl] impl BountyContract { pub fn tip(env: Env, token: Address, from: Address, to: Address, amount: i128) { + require_positive_amount(amount); from.require_auth(); let client = token::Client::new(&env, &token); client.transfer(&from, &to, &amount); } - pub fn create_bounty(env: Env, token: Address, maintainer: Address, issue_id: Symbol, amount: i128) { + pub fn create_bounty( + env: Env, + token: Address, + maintainer: Address, + issue_id: Symbol, + amount: i128, + ) { + require_positive_amount(amount); maintainer.require_auth(); - + let client = token::Client::new(&env, &token); - client.transfer(&maintainer, &env.current_contract_address(), &amount); - - let key = (symbol_short!("bounty"), issue_id.clone()); - let mut bounty: Map
= env.storage().persistent().get(&key).unwrap_or(Map::new(&env)); - + client.transfer(&maintainer, env.current_contract_address(), &amount); + + let key = bounty_key(issue_id); + let mut bounty: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(&env)); + if bounty.contains_key(maintainer.clone()) { panic!("Bounty already exists for this issue and maintainer"); } - - bounty.set(maintainer.clone(), amount); + + bounty.set(maintainer.clone(), BountyEscrow { token, amount }); env.storage().persistent().set(&key, &bounty); } - pub fn cancel_bounty(env: Env, token: Address, maintainer: Address, amount: i128) { + pub fn claim_bounty(env: Env, maintainer: Address, issue_id: Symbol, contributor: Address) { maintainer.require_auth(); - let client = token::Client::new(&env, &token); - client.transfer(&env.current_contract_address(), &maintainer, &amount); + + let escrow = take_bounty(&env, maintainer, issue_id); + let client = token::Client::new(&env, &escrow.token); + client.transfer(&env.current_contract_address(), contributor, &escrow.amount); + } + + pub fn get_bounty_amount(env: Env, maintainer: Address, issue_id: Symbol) -> i128 { + let key = bounty_key(issue_id); + let bounty: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(&env)); + + bounty.get(maintainer).map(|b| b.amount).unwrap_or(0) } + + pub fn cancel_bounty(env: Env, maintainer: Address, issue_id: Symbol) { + maintainer.require_auth(); + + let escrow = take_bounty(&env, maintainer.clone(), issue_id); + let client = token::Client::new(&env, &escrow.token); + client.transfer(&env.current_contract_address(), maintainer, &escrow.amount); + } +} + +fn bounty_key(issue_id: Symbol) -> (Symbol, Symbol) { + (symbol_short!("bounty"), issue_id) } + +fn require_positive_amount(amount: i128) { + if amount <= 0 { + panic!("Amount must be positive"); + } +} + +fn take_bounty(env: &Env, maintainer: Address, issue_id: Symbol) -> BountyEscrow { + let key = bounty_key(issue_id); + let mut bounty: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(env)); + let escrow = bounty + .get(maintainer.clone()) + .unwrap_or_else(|| panic!("Bounty not found")); + + bounty.remove(maintainer); + if bounty.is_empty() { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &bounty); + } + + escrow +} + +#[cfg(test)] +mod test; diff --git a/contracts/bounty/src/test.rs b/contracts/bounty/src/test.rs index 0bdcba0..728786b 100644 --- a/contracts/bounty/src/test.rs +++ b/contracts/bounty/src/test.rs @@ -1,21 +1,156 @@ -#![cfg(test)] +use super::{BountyContract, BountyContractClient}; +use soroban_sdk::{symbol_short, testutils::Address as _, token, Address, Env}; -use super::*; -use soroban_sdk::{vec, Env, String}; +struct TestFixture { + env: Env, + client: BountyContractClient<'static>, + token: Address, + maintainer: Address, + contributor: Address, + contract: Address, +} -#[test] -fn test() { +fn setup() -> TestFixture { let env = Env::default(); - let contract_id = env.register(Contract, ()); - let client = ContractClient::new(&env, &contract_id); + env.mock_all_auths(); + + let maintainer = Address::generate(&env); + let contributor = Address::generate(&env); + let token_admin = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token.address(); + let token_admin_client = token::StellarAssetClient::new(&env, &token_address); + token_admin_client.mint(&maintainer, &1_000); + + let contract = env.register(BountyContract, ()); + let client = BountyContractClient::new(&env, &contract); + + TestFixture { + env, + client, + token: token_address, + maintainer, + contributor, + contract, + } +} + +#[test] +fn create_bounty_locks_tokens_in_contract_escrow() { + let fixture = setup(); + let token_client = token::Client::new(&fixture.env, &fixture.token); + let issue_id = symbol_short!("ISSUE2"); - let words = client.hello(&String::from_str(&env, "Dev")); + fixture + .client + .create_bounty(&fixture.token, &fixture.maintainer, &issue_id, &250); + + assert_eq!(token_client.balance(&fixture.maintainer), 750); + assert_eq!(token_client.balance(&fixture.contract), 250); assert_eq!( - words, - vec![ - &env, - String::from_str(&env, "Hello"), - String::from_str(&env, "Dev"), - ] + fixture + .client + .get_bounty_amount(&fixture.maintainer, &issue_id), + 250 ); } + +#[test] +fn claim_bounty_releases_locked_tokens_to_contributor() { + let fixture = setup(); + let token_client = token::Client::new(&fixture.env, &fixture.token); + let issue_id = symbol_short!("ISSUE2"); + + fixture + .client + .create_bounty(&fixture.token, &fixture.maintainer, &issue_id, &250); + fixture + .client + .claim_bounty(&fixture.maintainer, &issue_id, &fixture.contributor); + + assert_eq!(token_client.balance(&fixture.maintainer), 750); + assert_eq!(token_client.balance(&fixture.contributor), 250); + assert_eq!(token_client.balance(&fixture.contract), 0); + assert_eq!( + fixture + .client + .get_bounty_amount(&fixture.maintainer, &issue_id), + 0 + ); +} + +#[test] +fn claim_bounty_uses_the_escrowed_token() { + let fixture = setup(); + let issue_id = symbol_short!("ISSUE2"); + let other_token_admin = Address::generate(&fixture.env); + let other_token = fixture + .env + .register_stellar_asset_contract_v2(other_token_admin.clone()); + let other_token_address = other_token.address(); + let other_admin_client = token::StellarAssetClient::new(&fixture.env, &other_token_address); + let other_token_client = token::Client::new(&fixture.env, &other_token_address); + let escrowed_token_client = token::Client::new(&fixture.env, &fixture.token); + + other_admin_client.mint(&fixture.contract, &999); + fixture + .client + .create_bounty(&fixture.token, &fixture.maintainer, &issue_id, &250); + fixture + .client + .claim_bounty(&fixture.maintainer, &issue_id, &fixture.contributor); + + assert_eq!(escrowed_token_client.balance(&fixture.contributor), 250); + assert_eq!(other_token_client.balance(&fixture.contributor), 0); + assert_eq!(other_token_client.balance(&fixture.contract), 999); +} + +#[test] +fn cancel_bounty_returns_locked_tokens_to_maintainer() { + let fixture = setup(); + let token_client = token::Client::new(&fixture.env, &fixture.token); + let issue_id = symbol_short!("ISSUE2"); + + fixture + .client + .create_bounty(&fixture.token, &fixture.maintainer, &issue_id, &250); + fixture.client.cancel_bounty(&fixture.maintainer, &issue_id); + + assert_eq!(token_client.balance(&fixture.maintainer), 1_000); + assert_eq!(token_client.balance(&fixture.contract), 0); + assert_eq!( + fixture + .client + .get_bounty_amount(&fixture.maintainer, &issue_id), + 0 + ); +} + +#[test] +#[should_panic(expected = "Bounty not found")] +fn claim_bounty_rejects_missing_escrow() { + let fixture = setup(); + + fixture.client.claim_bounty( + &fixture.maintainer, + &symbol_short!("NONE"), + &fixture.contributor, + ); +} + +#[test] +#[should_panic(expected = "Bounty not found")] +fn claim_bounty_cannot_be_paid_twice() { + let fixture = setup(); + let issue_id = symbol_short!("ISSUE2"); + + fixture + .client + .create_bounty(&fixture.token, &fixture.maintainer, &issue_id, &250); + fixture + .client + .claim_bounty(&fixture.maintainer, &issue_id, &fixture.contributor); + fixture + .client + .claim_bounty(&fixture.maintainer, &issue_id, &fixture.contributor); +}