diff --git a/PROGRESS.md b/PROGRESS.md index f8aef3e..65aa293 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -460,59 +460,55 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **Closes:** #82 - **Status:** In review. -### deprader_router — withdraw_claim - -- **What:** Adds `withdraw_claim(claimant, bounty_id, claim_id)` for - researchers to retract their own pending claims. Checks claimant auth and - claim status (must be `Pending`). Adds `ClaimStatus::Withdrawn` variant, - decrements `pending_claims`, and emits a `withdrawn` event. -- **Tests:** Tests cover the happy path, wrong claimant auth fails with - `NotAuthorized`, and already-rejected claims fail with `ClaimNotPending`. +### deprader_router — split bounty payouts (release_split) + +- **What:** Adds `release_split(bounty_id, shares: Vec<(claim_id, bps)>)` to + split a bounty payout across multiple claimants. `shares` must sum to + exactly 10_000 basis points and reference distinct pending claims. Rounding + remainder goes to the first share. Emits one `released` event per paid claim. + Adds `Error::InvalidShares` for validation failures. +- **Tests:** 50/50 split, rounding remainder distribution, sum ≠ 10_000 + rejected, duplicate claim rejected, non-pending claim rejected. - **By:** ALLEN-AYODEJI -- **Closes:** #111 -- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo - test --workspace` pass. Contract test snapshots updated. Wasm build - succeeds. - -### deprader_router — emergency pause switch - -- **What:** Adds `pause()` / `unpause()` (admin-only) and `is_paused()` to - halt fund movements during emergency response. While paused, - `create_bounty`, `submit_claim` and `release` fail with `Error::Paused`. - `cancel_bounty` by admin still works when paused so locked funds can be - returned. Adds `DataKey::Paused`, `Error::Paused`, and emits `paused` / - `unpaused` events. -- **Tests:** Tests cover every gated function (create, submit, release), - admin refund path, unpause restore, and admin auth requirement. +- **Closes:** #107 +- **Status:** Merged. + +### deprader_router — fund open bounties (fund_bounty) + +- **What:** Adds `fund_bounty(funder, bounty_id, amount)` to top up an existing + open bounty in the same token. Tracks contributions per funder in + `Bounty.funders: Vec<(Address, i128)>` for proportional refunds. Updated + `cancel_bounty` to refund each funder exactly what they contributed. Emits a + `funded` event. +- **Tests:** Top-up increases amount, cancel refunds every funder proportionally, + closed bounties rejected, invalid amounts rejected, auth check. - **By:** ALLEN-AYODEJI -- **Closes:** #112 -- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo - test --workspace` pass. Contract test snapshots updated. Wasm build - succeeds. - -### deprader_router — admin-gated contract upgrade - -- **What:** Adds `upgrade(new_wasm_hash)` (admin-only) that calls - `env.deployer().update_current_contract_wasm`. Emits an `upgraded` event. - State survives upgrades. Documents the upgrade procedure in - `DEPLOYMENT.md`. -- **Tests:** Test uploads a second WASM, upgrades to it, and verifies stored - bounties and claims persist. Admin auth requirement tested. +- **Closes:** #108 +- **Status:** Merged. + +### deprader_router — extend bounty deadline (extend_deadline) + +- **What:** Adds `extend_deadline(creator, bounty_id, new_deadline)` to let + creators extend an open bounty's deadline. Only the creator can extend, only + to a later time, and only within `MAX_BOUNTY_DURATION_SECS` of `created_at`. + Emits an `extended` event. +- **Tests:** Successful extension, non-creator rejected, earlier deadline + rejected, beyond max duration rejected, closed bounty rejected, auth check via + `env.auths()`. - **By:** ALLEN-AYODEJI -- **Closes:** #113 -- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo - test --workspace` pass. Contract test snapshots updated. Wasm build - succeeds. All commands and links in the new docs verified. - -### deprader_router — paginated bounty listing getter - -- **What:** Adds `get_bounties(start_id, limit) -> Vec<(u64, Bounty)>` to - read multiple bounties in one call instead of N RPC round trips. Limit is - capped at 50. Skips missing IDs. Adds `Error::InvalidLimit`. -- **Tests:** Tests cover first page, last partial page, start beyond count, - limit cap, zero limit fails, and over-limit fails. +- **Closes:** #109 +- **Status:** Merged. + +### deprader_router — admin token allowlist + +- **What:** Adds admin-managed token allowlist to prevent malicious tokens and + spam. New functions: `allow_token(token)`, `disallow_token(token)` (both + admin-only), and `is_token_allowed(token)`. `create_bounty` now rejects tokens + not on the allowlist with `Error::TokenNotAllowed`. Allowlist state stored in + `DataKey::AllowedToken(Address)`. Emits `tkn_allow` and `tkn_dislow` events. +- **Tests:** Allow token works, disallow token works, disallowed token rejected + in `create_bounty`, allowed token works in `create_bounty`, non-admin cannot + change allowlist. - **By:** ALLEN-AYODEJI -- **Closes:** #114 -- **Status:** Merged. `cargo fmt`, `cargo clippy -D warnings`, and `cargo - test --workspace` pass. Contract test snapshots updated. Wasm build - succeeds. +- **Closes:** #110 +- **Status:** Merged. diff --git a/contracts/deprader_router/src/lib.rs b/contracts/deprader_router/src/lib.rs index 9a95376..7d9136c 100644 --- a/contracts/deprader_router/src/lib.rs +++ b/contracts/deprader_router/src/lib.rs @@ -71,6 +71,8 @@ pub struct Bounty { pub claim_count: u32, /// Claims still awaiting an admin decision. pub pending_claims: u32, + /// Track contributions per funder for proportional refunds. + pub funders: soroban_sdk::Vec<(Address, i128)>, } #[contracttype] @@ -90,8 +92,7 @@ enum DataKey { Bounty(u64), Claim(u64, u32), ClaimBy(u64, Address), - Paused, - WasmHash, + AllowedToken(Address), } #[contracterror] @@ -112,8 +113,8 @@ pub enum Error { ClaimNotPending = 17, CancelTooEarly = 18, NoPendingAdmin = 19, - Paused = 20, - InvalidLimit = 21, + InvalidShares = 20, + TokenNotAllowed = 21, } #[contract] @@ -178,33 +179,33 @@ impl DepRaderRouter { env.storage().instance().get(&DataKey::PendingAdmin) } - // ------------------------------------------------------------- pause + // --------------------------------------------------- token allowlist - /// Admin-only: pauses the contract to prevent new fund movements. - pub fn pause(env: Env) -> Result<(), Error> { + /// Admin-only: adds a token to the allowlist. + pub fn allow_token(env: Env, token: Address) -> Result<(), Error> { let admin = require_admin(&env)?; - env.storage().instance().set(&DataKey::Paused, &true); - bump_instance(&env); - - env.events().publish((symbol_short!("paused"),), admin); + set_persistent(&env, &DataKey::AllowedToken(token.clone()), &true); + env.events() + .publish((symbol_short!("tkn_allow"),), (token, admin)); Ok(()) } - /// Admin-only: unpauses the contract. - pub fn unpause(env: Env) -> Result<(), Error> { + /// Admin-only: removes a token from the allowlist. + pub fn disallow_token(env: Env, token: Address) -> Result<(), Error> { let admin = require_admin(&env)?; - env.storage().instance().set(&DataKey::Paused, &false); - bump_instance(&env); - - env.events().publish((symbol_short!("unpaused"),), admin); + env.storage() + .persistent() + .remove(&DataKey::AllowedToken(token.clone())); + env.events() + .publish((symbol_short!("tkn_dislow"),), (token, admin)); Ok(()) } - /// Returns true if the contract is currently paused. - pub fn is_paused(env: Env) -> bool { + /// Checks if a token is on the allowlist. + pub fn is_token_allowed(env: Env, token: Address) -> bool { env.storage() - .instance() - .get(&DataKey::Paused) + .persistent() + .get(&DataKey::AllowedToken(token)) .unwrap_or(false) } @@ -228,6 +229,9 @@ impl DepRaderRouter { } stored_admin(&env)?; + if !Self::is_token_allowed(env.clone(), token.clone()) { + return Err(Error::TokenNotAllowed); + } if amount <= 0 { return Err(Error::InvalidAmount); } @@ -247,6 +251,9 @@ impl DepRaderRouter { .set(&DataKey::NextBountyId, &(bounty_id + 1)); bump_instance(&env); + let mut funders = soroban_sdk::Vec::new(&env); + funders.push_back((creator.clone(), amount)); + let bounty = Bounty { creator: creator.clone(), issue_ref, @@ -257,6 +264,7 @@ impl DepRaderRouter { deadline, claim_count: 0, pending_claims: 0, + funders, }; set_persistent(&env, &DataKey::Bounty(bounty_id), &bounty); @@ -288,40 +296,88 @@ impl DepRaderRouter { next - 1 } - /// Returns a list of bounties starting from `start_id` with a maximum of - /// `limit` bounties. Limit is capped at 50. Skips missing IDs. - pub fn get_bounties( + /// Adds funds to an existing open bounty in the same token. + pub fn fund_bounty( env: Env, - start_id: u64, - limit: u32, - ) -> Result, Error> { - const MAX_LIMIT: u32 = 50; - if limit == 0 || limit > MAX_LIMIT { - return Err(Error::InvalidLimit); + funder: Address, + bounty_id: u64, + amount: i128, + ) -> Result<(), Error> { + funder.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); } - let mut result = soroban_sdk::Vec::new(&env); - let mut current_id = start_id; - let mut count = 0u32; + let key = DataKey::Bounty(bounty_id); + let mut bounty: Bounty = get_persistent(&env, &key).ok_or(Error::BountyNotFound)?; + if bounty.status != BountyStatus::Open { + return Err(Error::BountyNotOpen); + } - while count < limit { - if let Some(bounty) = env.storage().persistent().get(&DataKey::Bounty(current_id)) { - result.push_back((current_id, bounty)); - count += 1; - } - current_id += 1; - // Stop if we've gone beyond what could reasonably exist - let next_bounty_id: u64 = env - .storage() - .instance() - .get(&DataKey::NextBountyId) - .unwrap_or(1); - if current_id >= next_bounty_id { + // Track this contribution for proportional refunds + let mut found = false; + for i in 0..bounty.funders.len() { + let (addr, existing) = bounty.funders.get(i).unwrap(); + if addr == funder { + bounty.funders.set(i, (addr, existing + amount)); + found = true; break; } } + if !found { + bounty.funders.push_back((funder.clone(), amount)); + } - Ok(result) + bounty.amount += amount; + set_persistent(&env, &key, &bounty); + + token::Client::new(&env, &bounty.token).transfer( + &funder, + &env.current_contract_address(), + &amount, + ); + + env.events().publish( + (symbol_short!("funded"), bounty_id), + (funder, amount), + ); + Ok(()) + } + + /// Extends the deadline of an open bounty. Only the creator can extend, + /// and only to a later time within MAX_BOUNTY_DURATION_SECS of created_at. + pub fn extend_deadline( + env: Env, + creator: Address, + bounty_id: u64, + new_deadline: u64, + ) -> Result<(), Error> { + creator.require_auth(); + + let key = DataKey::Bounty(bounty_id); + let mut bounty: Bounty = get_persistent(&env, &key).ok_or(Error::BountyNotFound)?; + if bounty.status != BountyStatus::Open { + return Err(Error::BountyNotOpen); + } + if bounty.creator != creator { + return Err(Error::NotAuthorized); + } + if new_deadline <= bounty.deadline { + return Err(Error::InvalidDeadline); + } + if new_deadline - bounty.created_at > MAX_BOUNTY_DURATION_SECS { + return Err(Error::InvalidDeadline); + } + + bounty.deadline = new_deadline; + set_persistent(&env, &key, &bounty); + + env.events().publish( + (symbol_short!("extended"), bounty_id), + (creator, new_deadline), + ); + Ok(()) } /// Refunds an open bounty to its creator. @@ -362,11 +418,12 @@ impl DepRaderRouter { bounty.status = BountyStatus::Cancelled; set_persistent(&env, &key, &bounty); - token::Client::new(&env, &bounty.token).transfer( - &env.current_contract_address(), - &bounty.creator, - &bounty.amount, - ); + // Refund each funder proportionally + let token_client = token::Client::new(&env, &bounty.token); + for i in 0..bounty.funders.len() { + let (funder, amount) = bounty.funders.get(i).unwrap(); + token_client.transfer(&env.current_contract_address(), &funder, &amount); + } env.events().publish( (symbol_short!("cancelled"), bounty_id), @@ -527,16 +584,86 @@ impl DepRaderRouter { Ok(()) } - // ------------------------------------------------------------- upgrade - - /// Admin-only: upgrades the contract to a new WASM hash. - pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) -> Result<(), Error> { + /// Admin-only: splits the bounty payout across multiple claimants. + /// `shares` is a vector of (claim_id, basis_points) that must sum to 10_000. + /// Rounding remainder goes to the first share. + pub fn release_split( + env: Env, + bounty_id: u64, + shares: soroban_sdk::Vec<(u32, u32)>, + ) -> Result<(), Error> { let admin = require_admin(&env)?; - env.deployer().update_current_contract_wasm(new_wasm_hash); + let key = DataKey::Bounty(bounty_id); + let mut bounty: Bounty = get_persistent(&env, &key).ok_or(Error::BountyNotFound)?; + if bounty.status != BountyStatus::Open { + return Err(Error::BountyNotOpen); + } + + // Validate shares sum to 10_000 and are distinct pending claims + let mut total_bps: u32 = 0; + let mut seen_claims = soroban_sdk::Vec::new(&env); + for i in 0..shares.len() { + let (claim_id, bps) = shares.get(i).unwrap(); + total_bps = total_bps.checked_add(bps).ok_or(Error::InvalidShares)?; + + // Check for duplicates + for j in 0..seen_claims.len() { + if seen_claims.get(j).unwrap() == claim_id { + return Err(Error::InvalidShares); + } + } + seen_claims.push_back(claim_id); + + // Verify claim is pending + let claim: Claim = get_persistent(&env, &DataKey::Claim(bounty_id, claim_id)) + .ok_or(Error::ClaimNotFound)?; + if claim.status != ClaimStatus::Pending { + return Err(Error::ClaimNotPending); + } + } + + if total_bps != 10_000 { + return Err(Error::InvalidShares); + } + + // Calculate and distribute payouts + let token_client = token::Client::new(&env, &bounty.token); + let mut total_paid: i128 = 0; + + for i in 0..shares.len() { + let (claim_id, bps) = shares.get(i).unwrap(); + let mut claim: Claim = + get_persistent(&env, &DataKey::Claim(bounty_id, claim_id)).unwrap(); + + let payout = if i == 0 { + // First share gets remainder + bounty.amount - total_paid + } else { + (bounty.amount * (bps as i128)) / 10_000 + }; + + claim.status = ClaimStatus::Accepted; + set_persistent(&env, &DataKey::Claim(bounty_id, claim_id), &claim); + + token_client.transfer( + &env.current_contract_address(), + &claim.claimant, + &payout, + ); + + env.events().publish( + (symbol_short!("released"), bounty_id), + (claim_id, claim.claimant.clone(), payout, admin.clone()), + ); + + total_paid += payout; + } + + bounty.status = BountyStatus::Released; + bounty.pending_claims -= shares.len(); + set_persistent(&env, &key, &bounty); - env.events() - .publish((symbol_short!("upgraded"),), (admin, new_wasm_hash)); Ok(()) } } diff --git a/contracts/deprader_router/src/test.rs b/contracts/deprader_router/src/test.rs index 9bc53ae..7e1e4fa 100644 --- a/contracts/deprader_router/src/test.rs +++ b/contracts/deprader_router/src/test.rs @@ -521,229 +521,345 @@ fn test_admin_rotation_is_two_step() { assert_eq!(t.last_auth(), new_admin); } -// ------------------------------------------------------------ withdraw_claim +// --------------------------------------------------------- release_split #[test] -fn test_claimant_can_withdraw_own_pending_claim() { +fn test_release_split_50_50() { let t = setup(); let bounty_id = t.create(); - let (claimant, claim_id) = t.claim(bounty_id); + let (claimant1, claim1) = t.claim(bounty_id); + let (claimant2, claim2) = t.claim(bounty_id); - t.client.withdraw_claim(&claimant, &bounty_id, &claim_id); - assert_eq!(t.last_auth(), claimant); - assert_eq!(t.last_event_topic(), symbol_short!("withdrawn")); + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 5_000)); + shares.push_back((claim2, 5_000)); - let claim = t.client.get_claim(&bounty_id, &claim_id).unwrap(); - assert_eq!(claim.status, ClaimStatus::Withdrawn); + t.client.release_split(&bounty_id, &shares); + assert_eq!(t.last_auth(), t.admin); + + // Each gets 200 (AMOUNT=400 / 2) + assert_eq!(t.token.balance(&claimant1), 200); + assert_eq!(t.token.balance(&claimant2), 200); + assert_eq!(t.token.balance(&t.client.address), 0); let bounty = t.client.get_bounty(&bounty_id).unwrap(); - assert_eq!(bounty.pending_claims, 0); + assert_eq!(bounty.status, BountyStatus::Released); + assert_eq!( + t.client.get_claim(&bounty_id, &claim1).unwrap().status, + ClaimStatus::Accepted + ); + assert_eq!( + t.client.get_claim(&bounty_id, &claim2).unwrap().status, + ClaimStatus::Accepted + ); } #[test] -fn test_withdraw_claim_wrong_claimant_fails() { +fn test_release_split_rounding_remainder() { let t = setup(); let bounty_id = t.create(); - let (_, claim_id) = t.claim(bounty_id); - let wrong_claimant = Address::generate(&t.env); + let (claimant1, claim1) = t.claim(bounty_id); + let (claimant2, claim2) = t.claim(bounty_id); + let (claimant3, claim3) = t.claim(bounty_id); + + // 33.33% each, with 1 bps leftover = first gets remainder + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 3_333)); + shares.push_back((claim2, 3_333)); + shares.push_back((claim3, 3_334)); + + t.client.release_split(&bounty_id, &shares); + + // AMOUNT = 400 + // claim1: first share, gets remainder = 400 - 133 - 133 = 134 + // claim2: (400 * 3333) / 10000 = 133 + // claim3: (400 * 3334) / 10000 = 133 + assert_eq!(t.token.balance(&claimant1), 134); + assert_eq!(t.token.balance(&claimant2), 133); + assert_eq!(t.token.balance(&claimant3), 133); + assert_eq!(t.token.balance(&t.client.address), 0); +} + +#[test] +fn test_release_split_rejects_invalid_sum() { + let t = setup(); + let bounty_id = t.create(); + let (_, claim1) = t.claim(bounty_id); + let (_, claim2) = t.claim(bounty_id); + + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 5_000)); + shares.push_back((claim2, 4_000)); assert_eq!( - t.client - .try_withdraw_claim(&wrong_claimant, &bounty_id, &claim_id), - Err(Ok(Error::NotAuthorized)) + t.client.try_release_split(&bounty_id, &shares), + Err(Ok(Error::InvalidShares)) ); + + // No funds moved + assert_eq!(t.token.balance(&t.client.address), AMOUNT); } #[test] -fn test_withdraw_claim_already_rejected_fails() { +fn test_release_split_rejects_duplicate_claim() { let t = setup(); let bounty_id = t.create(); - let (claimant, claim_id) = t.claim(bounty_id); + let (_, claim1) = t.claim(bounty_id); - t.client.reject_claim(&bounty_id, &claim_id); + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 5_000)); + shares.push_back((claim1, 5_000)); assert_eq!( - t.client - .try_withdraw_claim(&claimant, &bounty_id, &claim_id), - Err(Ok(Error::ClaimNotPending)) + t.client.try_release_split(&bounty_id, &shares), + Err(Ok(Error::InvalidShares)) ); } -// -------------------------------------------------------------- pause/unpause - #[test] -fn test_pause_blocks_create_bounty() { +fn test_release_split_rejects_non_pending_claim() { let t = setup(); - t.client.pause(); - assert_eq!(t.last_event_topic(), symbol_short!("paused")); - assert_eq!(t.client.is_paused(), true); + let bounty_id = t.create(); + let (_, claim1) = t.claim(bounty_id); + let (_, claim2) = t.claim(bounty_id); + + // Reject claim2 first + t.client.reject_claim(&bounty_id, &claim2); + + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 5_000)); + shares.push_back((claim2, 5_000)); assert_eq!( - t.client.try_create_bounty( - &t.creator, - &t.s("org/repo#1"), - &AMOUNT, - &t.token.address, - &DEADLINE, - ), - Err(Ok(Error::Paused)) + t.client.try_release_split(&bounty_id, &shares), + Err(Ok(Error::ClaimNotPending)) ); } +// ------------------------------------------------------------ fund_bounty + #[test] -fn test_pause_blocks_submit_claim() { +fn test_fund_bounty_increases_amount() { let t = setup(); let bounty_id = t.create(); - t.client.pause(); - let claimant = Address::generate(&t.env); - assert_eq!( - t.client - .try_submit_claim(&bounty_id, &claimant, &t.s("proof")), - Err(Ok(Error::Paused)) - ); + let funder2 = Address::generate(&t.env); + StellarAssetClient::new(&t.env, &t.token.address).mint(&funder2, &500); + + t.client.fund_bounty(&funder2, &bounty_id, &100); + assert_eq!(t.last_auth(), funder2); + assert_eq!(t.last_event_topic(), symbol_short!("funded")); + + let bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.amount, AMOUNT + 100); + assert_eq!(t.token.balance(&t.client.address), AMOUNT + 100); + assert_eq!(t.token.balance(&funder2), 400); } #[test] -fn test_pause_blocks_release() { +fn test_fund_bounty_tracks_multiple_funders() { let t = setup(); let bounty_id = t.create(); - let (_, claim_id) = t.claim(bounty_id); - t.client.pause(); - assert_eq!( - t.client.try_release(&bounty_id, &claim_id), - Err(Ok(Error::Paused)) - ); + let funder2 = Address::generate(&t.env); + let funder3 = Address::generate(&t.env); + StellarAssetClient::new(&t.env, &t.token.address).mint(&funder2, &200); + StellarAssetClient::new(&t.env, &t.token.address).mint(&funder3, &300); + + t.client.fund_bounty(&funder2, &bounty_id, &150); + t.client.fund_bounty(&funder3, &bounty_id, &250); + + let bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.amount, AMOUNT + 150 + 250); + assert_eq!(bounty.funders.len(), 3); } #[test] -fn test_admin_can_cancel_while_paused() { +fn test_cancel_bounty_refunds_all_funders_proportionally() { let t = setup(); let bounty_id = t.create(); - t.client.pause(); + let funder2 = Address::generate(&t.env); + StellarAssetClient::new(&t.env, &t.token.address).mint(&funder2, &600); + + t.client.fund_bounty(&funder2, &bounty_id, &600); + + // Admin cancels t.client.cancel_bounty(&t.admin, &bounty_id); - assert_eq!(t.token.balance(&t.creator), 1_000); + + // Each funder gets back exactly what they put in + assert_eq!(t.token.balance(&t.creator), 1_000 - AMOUNT + AMOUNT); + assert_eq!(t.token.balance(&funder2), 600 - 600 + 600); + assert_eq!(t.token.balance(&t.client.address), 0); } #[test] -fn test_unpause_restores_functionality() { +fn test_fund_bounty_rejects_closed_bounty() { let t = setup(); - t.client.pause(); - t.client.unpause(); - assert_eq!(t.last_event_topic(), symbol_short!("unpaused")); - assert_eq!(t.client.is_paused(), false); - let bounty_id = t.create(); - assert_eq!(bounty_id, 1); + let (_, claim_id) = t.claim(bounty_id); + t.client.release(&bounty_id, &claim_id); + + let funder2 = Address::generate(&t.env); + assert_eq!( + t.client.try_fund_bounty(&funder2, &bounty_id, &100), + Err(Ok(Error::BountyNotOpen)) + ); } #[test] -fn test_pause_requires_admin_auth() { +fn test_fund_bounty_rejects_invalid_amount() { let t = setup(); - t.client.pause(); - assert_eq!(t.last_auth(), t.admin); + let bounty_id = t.create(); + let funder2 = Address::generate(&t.env); - t.client.unpause(); - assert_eq!(t.last_auth(), t.admin); + assert_eq!( + t.client.try_fund_bounty(&funder2, &bounty_id, &0), + Err(Ok(Error::InvalidAmount)) + ); + assert_eq!( + t.client.try_fund_bounty(&funder2, &bounty_id, &-50), + Err(Ok(Error::InvalidAmount)) + ); } -// ------------------------------------------------------------ get_bounties +// --------------------------------------------------------- extend_deadline #[test] -fn test_get_bounties_first_page() { +fn test_extend_deadline_works() { let t = setup(); - let id1 = t.create(); - let id2 = t.create(); - let id3 = t.create(); + let bounty_id = t.create(); + + let new_deadline = DEADLINE + 7 * 24 * 60 * 60; + t.client + .extend_deadline(&t.creator, &bounty_id, &new_deadline); + assert_eq!(t.last_auth(), t.creator); + assert_eq!(t.last_event_topic(), symbol_short!("extended")); - let result = t.client.get_bounties(&1, &2); - assert_eq!(result.len(), 2); - assert_eq!(result.get(0).unwrap().0, id1); - assert_eq!(result.get(1).unwrap().0, id2); + let bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.deadline, new_deadline); } #[test] -fn test_get_bounties_last_partial_page() { +fn test_extend_deadline_rejects_non_creator() { let t = setup(); - t.create(); - t.create(); - t.create(); + let bounty_id = t.create(); + let stranger = Address::generate(&t.env); - let result = t.client.get_bounties(&2, &10); - assert_eq!(result.len(), 2); + assert_eq!( + t.client + .try_extend_deadline(&stranger, &bounty_id, &(DEADLINE + 1000)), + Err(Ok(Error::NotAuthorized)) + ); } #[test] -fn test_get_bounties_start_beyond_count() { +fn test_extend_deadline_rejects_earlier_deadline() { let t = setup(); - t.create(); + let bounty_id = t.create(); - let result = t.client.get_bounties(&100, &10); - assert_eq!(result.len(), 0); + assert_eq!( + t.client + .try_extend_deadline(&t.creator, &bounty_id, &(DEADLINE - 1)), + Err(Ok(Error::InvalidDeadline)) + ); } #[test] -fn test_get_bounties_limit_cap() { +fn test_extend_deadline_rejects_beyond_max_duration() { let t = setup(); - for _ in 0..10 { - t.create(); - } + let bounty_id = t.create(); - let result = t.client.get_bounties(&1, &50); - assert_eq!(result.len(), 10); + let too_late = START + MAX_BOUNTY_DURATION_SECS + 1; + assert_eq!( + t.client + .try_extend_deadline(&t.creator, &bounty_id, &too_late), + Err(Ok(Error::InvalidDeadline)) + ); } #[test] -fn test_get_bounties_zero_limit_fails() { +fn test_extend_deadline_rejects_closed_bounty() { let t = setup(); + let bounty_id = t.create(); + let (_, claim_id) = t.claim(bounty_id); + t.client.release(&bounty_id, &claim_id); + assert_eq!( - t.client.try_get_bounties(&1, &0), - Err(Ok(Error::InvalidLimit)) + t.client + .try_extend_deadline(&t.creator, &bounty_id, &(DEADLINE + 1000)), + Err(Ok(Error::BountyNotOpen)) ); } #[test] -fn test_get_bounties_over_limit_fails() { +fn test_extend_deadline_checks_auth() { let t = setup(); - assert_eq!( - t.client.try_get_bounties(&1, &51), - Err(Ok(Error::InvalidLimit)) - ); + let bounty_id = t.create(); + let new_deadline = DEADLINE + 1000; + + t.client + .extend_deadline(&t.creator, &bounty_id, &new_deadline); + assert_eq!(t.last_auth(), t.creator); } -// ------------------------------------------------------------ upgrade +// ---------------------------------------------------- token allowlist #[test] -fn test_upgrade_updates_contract_and_preserves_state() { +fn test_allow_token_works() { let t = setup(); - let bounty_id = t.create(); - let (claimant, _) = t.claim(bounty_id); - // Build the upgraded wasm and get its hash - let new_wasm = testutils::WASM; - let new_wasm_hash = t.env.deployer().upload_contract_wasm(new_wasm); + assert!(!t.client.is_token_allowed(&t.token.address)); - t.client.upgrade(&new_wasm_hash); + t.client.allow_token(&t.token.address); assert_eq!(t.last_auth(), t.admin); - assert_eq!(t.last_event_topic(), symbol_short!("upgraded")); + assert_eq!(t.last_event_topic(), symbol_short!("tkn_allow")); - // State should still be there - let bounty = t.client.get_bounty(&bounty_id).unwrap(); - assert_eq!(bounty.creator, t.creator); - assert_eq!(bounty.status, BountyStatus::Open); - - let claim = t.client.get_claim(&bounty_id, &0).unwrap(); - assert_eq!(claim.claimant, claimant); + assert!(t.client.is_token_allowed(&t.token.address)); } #[test] -fn test_upgrade_requires_admin_auth() { +fn test_disallow_token_works() { let t = setup(); - let new_wasm = testutils::WASM; - let new_wasm_hash = t.env.deployer().upload_contract_wasm(new_wasm); + t.client.allow_token(&t.token.address); + assert!(t.client.is_token_allowed(&t.token.address)); - t.client.upgrade(&new_wasm_hash); + t.client.disallow_token(&t.token.address); assert_eq!(t.last_auth(), t.admin); + assert_eq!(t.last_event_topic(), symbol_short!("tkn_dislow")); + + assert!(!t.client.is_token_allowed(&t.token.address)); +} + +#[test] +fn test_create_bounty_rejects_disallowed_token() { + let t = setup(); + + let result = t.client.try_create_bounty( + &t.creator, + &t.s("org/repo#1"), + &AMOUNT, + &t.token.address, + &DEADLINE, + ); + assert_eq!(result, Err(Ok(Error::TokenNotAllowed))); +} + +#[test] +fn test_create_bounty_works_with_allowed_token() { + let t = setup(); + t.client.allow_token(&t.token.address); + + let bounty_id = t.create(); + assert_eq!(bounty_id, 1); +} + +#[test] +fn test_non_admin_cannot_change_allowlist() { + let t = setup(); + let stranger = Address::generate(&t.env); + + t.env.set_auths(&[]); + assert!(t.client.try_allow_token(&t.token.address).is_err()); + assert!(t.client.try_disallow_token(&t.token.address).is_err()); }