From 4f575d48c3fe3b9e5d6ccaa802095cc444b49b0a Mon Sep 17 00:00:00 2001 From: Folex1275 Date: Mon, 19 Oct 2026 21:32:33 +0100 Subject: [PATCH] feat(deprader_router): add bounty funding, deadline extension, and token allowlist --- PROGRESS.md | 53 ++++ contracts/deprader_router/src/lib.rs | 220 ++++++++++++++++- contracts/deprader_router/src/test.rs | 343 ++++++++++++++++++++++++++ 3 files changed, 611 insertions(+), 5 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 7da9611..65aa293 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -459,3 +459,56 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** eeshasalaudeen734-creator - **Closes:** #82 - **Status:** In review. + +### 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:** #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:** #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:** #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:** #110 +- **Status:** Merged. diff --git a/contracts/deprader_router/src/lib.rs b/contracts/deprader_router/src/lib.rs index ca0ef3a..902a3b6 100644 --- a/contracts/deprader_router/src/lib.rs +++ b/contracts/deprader_router/src/lib.rs @@ -70,6 +70,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] @@ -89,6 +91,7 @@ enum DataKey { Bounty(u64), Claim(u64, u32), ClaimBy(u64, Address), + AllowedToken(Address), } #[contracterror] @@ -109,6 +112,8 @@ pub enum Error { ClaimNotPending = 17, CancelTooEarly = 18, NoPendingAdmin = 19, + InvalidShares = 20, + TokenNotAllowed = 21, } #[contract] @@ -173,6 +178,36 @@ impl DepRaderRouter { env.storage().instance().get(&DataKey::PendingAdmin) } + // --------------------------------------------------- token allowlist + + /// Admin-only: adds a token to the allowlist. + pub fn allow_token(env: Env, token: Address) -> Result<(), Error> { + let admin = require_admin(&env)?; + set_persistent(&env, &DataKey::AllowedToken(token.clone()), &true); + env.events() + .publish((symbol_short!("tkn_allow"),), (token, admin)); + Ok(()) + } + + /// 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() + .persistent() + .remove(&DataKey::AllowedToken(token.clone())); + env.events() + .publish((symbol_short!("tkn_dislow"),), (token, admin)); + Ok(()) + } + + /// Checks if a token is on the allowlist. + pub fn is_token_allowed(env: Env, token: Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::AllowedToken(token)) + .unwrap_or(false) + } + // ------------------------------------------------------------- bounties /// Locks `amount` of `token` from `creator` against a new bounty and @@ -189,6 +224,9 @@ impl DepRaderRouter { creator.require_auth(); stored_admin(&env)?; + if !Self::is_token_allowed(env.clone(), token.clone()) { + return Err(Error::TokenNotAllowed); + } if amount <= 0 { return Err(Error::InvalidAmount); } @@ -208,6 +246,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, @@ -218,6 +259,7 @@ impl DepRaderRouter { deadline, claim_count: 0, pending_claims: 0, + funders, }; set_persistent(&env, &DataKey::Bounty(bounty_id), &bounty); @@ -249,6 +291,90 @@ impl DepRaderRouter { next - 1 } + /// Adds funds to an existing open bounty in the same token. + pub fn fund_bounty( + env: Env, + funder: Address, + bounty_id: u64, + amount: i128, + ) -> Result<(), Error> { + funder.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + 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); + } + + // 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)); + } + + 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. /// /// - The admin may cancel at any time (e.g. the finding was invalid). @@ -281,11 +407,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), @@ -401,6 +528,89 @@ impl DepRaderRouter { ); Ok(()) } + + /// 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)?; + + 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); + + Ok(()) + } } // ------------------------------------------------------------------ helpers diff --git a/contracts/deprader_router/src/test.rs b/contracts/deprader_router/src/test.rs index b30f566..492cb68 100644 --- a/contracts/deprader_router/src/test.rs +++ b/contracts/deprader_router/src/test.rs @@ -513,3 +513,346 @@ fn test_admin_rotation_is_two_step() { t.client.release(&bounty_id, &claim_id); assert_eq!(t.last_auth(), new_admin); } + +// --------------------------------------------------------- release_split + +#[test] +fn test_release_split_50_50() { + let t = setup(); + let bounty_id = t.create(); + let (claimant1, claim1) = t.claim(bounty_id); + let (claimant2, claim2) = t.claim(bounty_id); + + let mut shares = soroban_sdk::Vec::new(&t.env); + shares.push_back((claim1, 5_000)); + shares.push_back((claim2, 5_000)); + + 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.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_release_split_rounding_remainder() { + let t = setup(); + let bounty_id = t.create(); + 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_release_split(&bounty_id, &shares), + Err(Ok(Error::InvalidShares)) + ); + + // No funds moved + assert_eq!(t.token.balance(&t.client.address), AMOUNT); +} + +#[test] +fn test_release_split_rejects_duplicate_claim() { + let t = setup(); + let bounty_id = t.create(); + let (_, claim1) = t.claim(bounty_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_release_split(&bounty_id, &shares), + Err(Ok(Error::InvalidShares)) + ); +} + +#[test] +fn test_release_split_rejects_non_pending_claim() { + let t = setup(); + 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_release_split(&bounty_id, &shares), + Err(Ok(Error::ClaimNotPending)) + ); +} + +// ------------------------------------------------------------ fund_bounty + +#[test] +fn test_fund_bounty_increases_amount() { + let t = setup(); + let bounty_id = t.create(); + + 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_fund_bounty_tracks_multiple_funders() { + let t = setup(); + let bounty_id = t.create(); + + 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_cancel_bounty_refunds_all_funders_proportionally() { + let t = setup(); + let bounty_id = t.create(); + + 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); + + // 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_fund_bounty_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); + + let funder2 = Address::generate(&t.env); + assert_eq!( + t.client.try_fund_bounty(&funder2, &bounty_id, &100), + Err(Ok(Error::BountyNotOpen)) + ); +} + +#[test] +fn test_fund_bounty_rejects_invalid_amount() { + let t = setup(); + let bounty_id = t.create(); + let funder2 = Address::generate(&t.env); + + 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)) + ); +} + +// --------------------------------------------------------- extend_deadline + +#[test] +fn test_extend_deadline_works() { + let t = setup(); + 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 bounty = t.client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.deadline, new_deadline); +} + +#[test] +fn test_extend_deadline_rejects_non_creator() { + let t = setup(); + let bounty_id = t.create(); + let stranger = Address::generate(&t.env); + + assert_eq!( + t.client + .try_extend_deadline(&stranger, &bounty_id, &(DEADLINE + 1000)), + Err(Ok(Error::NotAuthorized)) + ); +} + +#[test] +fn test_extend_deadline_rejects_earlier_deadline() { + let t = setup(); + let bounty_id = t.create(); + + assert_eq!( + t.client + .try_extend_deadline(&t.creator, &bounty_id, &(DEADLINE - 1)), + Err(Ok(Error::InvalidDeadline)) + ); +} + +#[test] +fn test_extend_deadline_rejects_beyond_max_duration() { + let t = setup(); + let bounty_id = t.create(); + + 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_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_extend_deadline(&t.creator, &bounty_id, &(DEADLINE + 1000)), + Err(Ok(Error::BountyNotOpen)) + ); +} + +#[test] +fn test_extend_deadline_checks_auth() { + let t = setup(); + 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); +} + +// ---------------------------------------------------- token allowlist + +#[test] +fn test_allow_token_works() { + let t = setup(); + + assert!(!t.client.is_token_allowed(&t.token.address)); + + t.client.allow_token(&t.token.address); + assert_eq!(t.last_auth(), t.admin); + assert_eq!(t.last_event_topic(), symbol_short!("tkn_allow")); + + assert!(t.client.is_token_allowed(&t.token.address)); +} + +#[test] +fn test_disallow_token_works() { + let t = setup(); + t.client.allow_token(&t.token.address); + assert!(t.client.is_token_allowed(&t.token.address)); + + 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()); +}