From 8fd86b010822fb49eba567f501a9729c1c04a7c0 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 18:40:37 +0000 Subject: [PATCH] feat: make MAX_SPONSORS configurable per deployment (#96) - Add optional max_sponsors parameter to initialize() in both escrow and milestones contracts - Store limit in instance storage under DataKey::MaxSponsors - Default to 20 (DEFAULT_MAX_SPONSORS) when not specified for backward compatibility - Reject zero value with InvalidAmount error - Update all tests to pass None for new parameter - Fixes #96 --- contracts/escrow/src/lib.rs | 12 ++++++++++-- contracts/escrow/src/test.rs | 14 +++++++------- contracts/escrow/src/types.rs | 1 + contracts/milestones/src/lib.rs | 12 ++++++++++-- contracts/milestones/src/test.rs | 10 +++++----- contracts/milestones/src/types.rs | 1 + 6 files changed, 34 insertions(+), 16 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 7d96937..fb6e547 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -24,7 +24,8 @@ pub const BPS_DENOMINATOR: i128 = 10_000; /// accumulate. Bounds the per-contributor loops in `refund` and /// `extend_deadline` to a small, predictable constant regardless of how /// popular a bounty gets. See `docs/escrow-crowdfunding-design.md`. -pub const MAX_SPONSORS: u32 = 20; +/// Default maximum number of sponsors if not configured at initialization. +pub const DEFAULT_MAX_SPONSORS: u32 = 20; /// Minimum grace period (in seconds) after the deadline before anyone can permissionlessly trigger a refund. /// This prevents a race condition where a legitimate release in-flight near the deadline gets front-run by a refund. @@ -52,6 +53,7 @@ impl EscrowContract { admin: Address, treasury: Address, fee_bps: u32, + max_sponsors: Option, ) -> Result<(), Error> { admin.require_auth(); @@ -65,6 +67,11 @@ impl EscrowContract { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Treasury, &treasury); env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + let limit = max_sponsors.unwrap_or(DEFAULT_MAX_SPONSORS); + if limit == 0 { + return Err(Error::InvalidAmount); + } + env.storage().instance().set(&DataKey::MaxSponsors, &limit); Ok(()) } @@ -158,7 +165,8 @@ impl EscrowContract { EscrowStatus::Funded => {} } - if escrow.contributor_count >= MAX_SPONSORS { + let max_sponsors: u32 = env.storage().instance().get(&DataKey::MaxSponsors).unwrap_or(DEFAULT_MAX_SPONSORS); + if escrow.contributor_count >= max_sponsors { return Err(Error::TooManySponsors); } diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 91e85bd..115ea22 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -24,7 +24,7 @@ fn setup(env: &Env) -> (Address, Address, Address, EscrowContractClient<'_>) { let treasury = Address::generate(env); let contract_id = env.register(EscrowContract, ()); let client = EscrowContractClient::new(env, &contract_id); - client.initialize(&admin, &treasury, &500u32); // 5% fee + client.initialize(&admin, &treasury, &500u32, &None); // 5% fee (contract_id, admin, treasury, client) } @@ -33,7 +33,7 @@ fn test_initialize_rejects_double_init() { let env = Env::default(); env.mock_all_auths(); let (_, admin, treasury, client) = setup(&env); - let err = client.try_initialize(&admin, &treasury, &500u32); + let err = client.try_initialize(&admin, &treasury, &500u32, &None); assert_eq!(err, Err(Ok(Error::AlreadyInitialized))); } @@ -247,7 +247,7 @@ fn test_adversarial_ordering_resistance() { let client = crate::EscrowContractClient::new(&env, &contract_id); // Initialize with 0% fee to simplify fraction/dust calculations - client.initialize(&admin, &treasury, &0u32); + client.initialize(&admin, &treasury, &0u32, &None); // 2. Create recipient addresses let dev1 = Address::generate(&env); @@ -312,7 +312,7 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let contract_id = env.register(crate::EscrowContract, ()); let client = crate::EscrowContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. - client.initialize(&admin, &treasury, &0u32); + client.initialize(&admin, &treasury, &0u32, &None); // 60 recipients: 59 with alternating 160/170 bps, the last one receiving // the leftover of 10000. All 170-bps recipients share an identical @@ -400,7 +400,7 @@ fn test_initialize_requires_admin_auth() { let contract_id = env.register(EscrowContract, ()); let client = EscrowContractClient::new(&env, &contract_id); - let result = client.try_initialize(&admin, &treasury, &500u32); + let result = client.try_initialize(&admin, &treasury, &500u32, &None); assert!(result.is_err()); } @@ -732,14 +732,14 @@ fn test_contribute_rejects_beyond_max_sponsors() { // MAX_SPONSORS is 20; alice's `fund` call above already used slot 0, so // 19 more `contribute` calls exactly fill the cap. - for _ in 0..(crate::MAX_SPONSORS - 1) { + for _ in 0..(crate::DEFAULT_MAX_SPONSORS - 1) { let extra = Address::generate(&env); asset_client.mint(&extra, &1_000i128); client.contribute(&105u64, &extra, &1_000i128); } assert_eq!( client.get_escrow(&105u64).contributor_count, - crate::MAX_SPONSORS + crate::DEFAULT_MAX_SPONSORS ); // The 21st distinct contribution is rejected. diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index 7949410..dc65d2d 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -38,6 +38,7 @@ pub enum DataKey { Admin, Treasury, FeeBps, + MaxSponsors, Escrow(u64), Contribution(u64, u32), // (issue_id, contribution_index) } diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 80c5210..04e1fef 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -28,7 +28,8 @@ pub const BPS_DENOMINATOR: i128 = 10_000; /// (and any future timeout-triggered wind-down that reuses /// `refund_remaining_budget`) to a small, predictable constant regardless /// of how popular a release gets. See `docs/milestones-crowdfunding-design.md`. -pub const MAX_SPONSORS: u32 = 20; +/// Default maximum number of sponsors if not configured at initialization. +pub const DEFAULT_MAX_SPONSORS: u32 = 20; #[contract] pub struct MilestonesContract; @@ -44,6 +45,7 @@ impl MilestonesContract { admin: Address, treasury: Address, fee_bps: u32, + max_sponsors: Option, ) -> Result<(), Error> { admin.require_auth(); @@ -56,6 +58,11 @@ impl MilestonesContract { env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Treasury, &treasury); env.storage().instance().set(&DataKey::FeeBps, &fee_bps); + let limit = max_sponsors.unwrap_or(DEFAULT_MAX_SPONSORS); + if limit == 0 { + return Err(Error::InvalidAmount); + } + env.storage().instance().set(&DataKey::MaxSponsors, &limit); Ok(()) } @@ -144,7 +151,8 @@ impl MilestonesContract { if milestone.closed { return Err(Error::MilestoneClosed); } - if milestone.contributor_count >= MAX_SPONSORS { + let max_sponsors: u32 = env.storage().instance().get(&DataKey::MaxSponsors).unwrap_or(DEFAULT_MAX_SPONSORS); + if milestone.contributor_count >= max_sponsors { return Err(Error::TooManySponsors); } diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index d7c4b7c..74b9abf 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -21,7 +21,7 @@ fn setup(env: &Env) -> (Address, Address, MilestonesContractClient<'_>) { let treasury = Address::generate(env); let contract_id = env.register(MilestonesContract, ()); let client = MilestonesContractClient::new(env, &contract_id); - client.initialize(&admin, &treasury, &500u32); // 5% fee + client.initialize(&admin, &treasury, &500u32, &None); // 5% fee (admin, treasury, client) } @@ -109,7 +109,7 @@ fn test_large_split_distributes_dust_by_largest_remainder() { let contract_id = env.register(crate::MilestonesContract, ()); let client = crate::MilestonesContractClient::new(&env, &contract_id); // 0% fee so the whole total is distributable. - client.initialize(&admin, &treasury, &0u32); + client.initialize(&admin, &treasury, &0u32, &None); // 60 recipients: 59 with alternating 160/170 bps, the last one receiving // the leftover of 10000. All 170-bps recipients share an identical @@ -261,7 +261,7 @@ fn test_initialize_requires_admin_auth() { let contract_id = env.register(MilestonesContract, ()); let client = MilestonesContractClient::new(&env, &contract_id); - let result = client.try_initialize(&admin, &treasury, &500u32); + let result = client.try_initialize(&admin, &treasury, &500u32, &None); assert!(result.is_err()); } @@ -516,14 +516,14 @@ fn test_contribute_rejects_beyond_max_sponsors() { // MAX_SPONSORS is 20; alice's `create_milestone` above already used // slot 0, so 19 more `contribute` calls exactly fill the cap. - for _ in 0..(crate::MAX_SPONSORS - 1) { + for _ in 0..(crate::DEFAULT_MAX_SPONSORS - 1) { let extra = Address::generate(&env); asset_client.mint(&extra, &1_000i128); client.contribute(&56u64, &extra, &1_000i128); } assert_eq!( client.get_milestone(&56u64).contributor_count, - crate::MAX_SPONSORS + crate::DEFAULT_MAX_SPONSORS ); // The 21st distinct contribution is rejected. diff --git a/contracts/milestones/src/types.rs b/contracts/milestones/src/types.rs index a042a73..18e98ea 100644 --- a/contracts/milestones/src/types.rs +++ b/contracts/milestones/src/types.rs @@ -59,6 +59,7 @@ pub enum DataKey { Admin, Treasury, FeeBps, + MaxSponsors, Milestone(u64), IssueStatus(u64, u64), // (milestone_id, issue_id) Contribution(u64, u32), // (milestone_id, contribution_index)