From 7b8b301eaff1eb5215757b02b9ee55679c003ce4 Mon Sep 17 00:00:00 2001 From: ZuLu0890 Date: Mon, 17 Aug 2026 03:11:07 +0000 Subject: [PATCH] feat(milestones): multi-sponsor crowdfunding with proportional refund on cancel (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a contribution ledger (create_milestone creates, contribute appends, MAX_SPONSORS-capped) so several sponsors can co-fund one milestone, and make cancel_milestone refund the unallocated remainder to every contributor in proportion to what they put in — not the nominal total — using largest-remainder rounding. Lands before the deallocate/reallocate and timeout-escape-hatch issues so neither ever has to design against a single-sponsor-only refund; see docs/milestones-crowdfunding-design.md. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- README.md | 52 ++++-- contracts/milestones/src/error.rs | 1 + contracts/milestones/src/lib.rs | 208 +++++++++++++++++++++-- contracts/milestones/src/test.rs | 226 +++++++++++++++++++++++++ contracts/milestones/src/types.rs | 39 ++++- docs/milestones-crowdfunding-design.md | 158 +++++++++++++++++ 6 files changed, 652 insertions(+), 32 deletions(-) create mode 100644 docs/milestones-crowdfunding-design.md diff --git a/README.md b/README.md index 6035547..b9ab201 100644 --- a/README.md +++ b/README.md @@ -153,15 +153,33 @@ Lump-sum budget shared across the issues in a release. ```rust fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>; fn create_milestone(env, milestone_id: u64, sponsor: Address, token: Address, total_budget: i128) -> Result<(), Error>; +fn contribute(env, milestone_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>; fn allocate(env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error>; fn release_issue(env, milestone_id: u64, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error>; fn cancel_milestone(env, milestone_id: u64) -> Result<(), Error>; fn get_milestone(env, milestone_id: u64) -> Result; fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result; +fn get_contribution(env, milestone_id: u64, index: u32) -> Result; ``` -- `create_milestone`: sponsor deposits `total_budget` once; the pool - starts fully unallocated (`remaining_budget == total_budget`). +- `create_milestone`: the original sponsor deposits `total_budget` once; + the pool starts fully unallocated (`remaining_budget == total_budget`). + One milestone per `milestone_id` — a second `create_milestone` on the + same id is rejected; every sponsor after the first uses `contribute` + instead. +- `contribute`: `sponsor.require_auth()`. Adds an additional sponsor's + funds to an already-`create_milestone`d pool — this is how crowdfunding + a release across several sponsors works. Uses the token already + recorded on the milestone (no `token` param, so a top-up can't silently + use a different asset). New funds arrive unallocated, so both + `total_budget` and `remaining_budget` grow by the contribution. Each + contribution is recorded individually (`Contribution { sponsor, amount + }`, queryable via `get_contribution`, with the original funder always + at index 0) so a cancellation refund can return each sponsor's + proportional share to their own address. Capped at `MAX_SPONSORS` (20) + distinct contributions per milestone (`TooManySponsors` otherwise). + Rejects `MilestoneClosed`. See + `docs/milestones-crowdfunding-design.md` for the full design reasoning. - `allocate`: admin-only. Reserves a slice of `remaining_budget` for a specific `issue_id`. Over-allocating past what's left is rejected (`OverAllocation`); allocating an issue twice is rejected @@ -170,9 +188,15 @@ fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result, // issue_id -> allocated amount + pub contributor_count: u32, // enumerate via get_contribution(0..contributor_count) +} +pub struct Contribution { + pub sponsor: Address, + pub amount: i128, } pub enum IssueStatus { Allocated, Released } @@ -382,9 +411,10 @@ cargo build --target wasm32v1-none --release \ -p mergefi-escrow -p mergefi-milestones -p mergefi-maintenance-pool ``` -Verified in this session: `cargo test --workspace` — **34/34 tests pass** -(17 escrow, 10 milestones, 7 maintenance-pool, including the -access-control boundary matrix added in #30) on the native target using +Verified in this session: `cargo test --workspace` — **54/54 tests pass** +(28 escrow, 19 milestones, 7 maintenance-pool, including the +access-control boundary matrix added in #30 and the multi-sponsor +crowdfunding tests added in #57/#58) on the native target using `soroban_sdk::testutils` (`Env::default()`, `Address::generate`, `mock_all_auths`, `register_stellar_asset_contract_v2` for a test token). The `wasm32v1-none` release build was also verified — all three contracts diff --git a/contracts/milestones/src/error.rs b/contracts/milestones/src/error.rs index f29f965..e0d7d45 100644 --- a/contracts/milestones/src/error.rs +++ b/contracts/milestones/src/error.rs @@ -16,4 +16,5 @@ pub enum Error { InvalidAmount = 10, InvalidFee = 11, MilestoneClosed = 12, + TooManySponsors = 13, } diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index a4b2ad9..08781e7 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -1,11 +1,14 @@ //! MergeFi Milestone Funding Contract //! -//! A milestone pools a sponsor's lump-sum budget across multiple GitHub -//! issues that make up a release. The sponsor deposits once; the backend -//! oracle allocates slices of the budget to individual issues and later -//! releases each allocation (optionally split across a team) as issues are -//! merged, exactly like the escrow contract's `release`, but drawn from a -//! shared pool instead of a single-issue deposit. +//! A milestone pools one or more sponsors' contributions into a lump-sum +//! budget shared across multiple GitHub issues that make up a release. +//! The first sponsor deposits to open the pool; the backend oracle +//! allocates slices of the budget to individual issues and later releases +//! each allocation (optionally split across a team) as issues are merged, +//! exactly like the escrow contract's `release`, but drawn from a shared +//! pool instead of a single-issue deposit. If the release is cancelled, +//! the unallocated remainder is refunded to every contributor in +//! proportion to what they put in. #![no_std] mod error; @@ -16,10 +19,17 @@ mod test; use error::Error; use soroban_sdk::{contract, contractimpl, token, Address, Env, Map, Vec}; -use types::{DataKey, IssueStatus, Milestone}; +use types::{Contribution, DataKey, IssueStatus, Milestone}; pub const BPS_DENOMINATOR: i128 = 10_000; +/// Maximum number of distinct contributions (sponsors) a single milestone +/// can accumulate. Bounds the per-contributor loop in `cancel_milestone` +/// (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; + #[contract] pub struct MilestonesContract; @@ -49,8 +59,13 @@ impl MilestonesContract { Ok(()) } - /// Sponsor deposits `total_budget` of `token` to open a new milestone - /// pool. Requires sponsor authorization. + /// The original sponsor deposits `total_budget` of `token` to open a + /// new milestone pool. Requires that sponsor's authorization. One + /// milestone per `milestone_id` — a second `create_milestone` call on + /// the same id is rejected (`IssueAlreadyAllocated`); every sponsor + /// after the first uses `contribute` instead. See + /// `docs/milestones-crowdfunding-design.md` for why creation and + /// contribution are kept as two separate entrypoints. pub fn create_milestone( env: Env, milestone_id: u64, @@ -72,6 +87,18 @@ impl MilestonesContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&sponsor, env.current_contract_address(), &total_budget); + // The original funder is always contribution index 0, exactly like + // `escrow::fund`; every later sponsor appends via `contribute`. + let contribution_key = DataKey::Contribution(milestone_id, 0); + env.storage().persistent().set( + &contribution_key, + &Contribution { + sponsor: sponsor.clone(), + amount: total_budget, + }, + ); + extend_ttl(&env, &contribution_key); + let milestone = Milestone { sponsor, token, @@ -80,12 +107,68 @@ impl MilestonesContract { created_at: env.ledger().timestamp(), closed: false, allocations: Map::new(&env), + contributor_count: 1, }; env.storage().persistent().set(&key, &milestone); extend_ttl(&env, &key); Ok(()) } + /// Adds an additional sponsor's contribution to an already-created + /// milestone, enabling crowdfunding: several sponsors can co-fund the + /// same `milestone_id`. Requires the contributing sponsor's + /// authorization. Uses the token already recorded on the milestone (no + /// `token` parameter), so a top-up can never silently use a different + /// asset than the original funder intended. Rejects `MilestoneNotFound`, + /// `MilestoneClosed`, and `TooManySponsors` once `MAX_SPONSORS` + /// contributions have already been recorded. + pub fn contribute( + env: Env, + milestone_id: u64, + sponsor: Address, + amount: i128, + ) -> Result<(), Error> { + sponsor.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let mkey = DataKey::Milestone(milestone_id); + let mut milestone: Milestone = env + .storage() + .persistent() + .get(&mkey) + .ok_or(Error::MilestoneNotFound)?; + + if milestone.closed { + return Err(Error::MilestoneClosed); + } + if milestone.contributor_count >= MAX_SPONSORS { + return Err(Error::TooManySponsors); + } + + let token_client = token::Client::new(&env, &milestone.token); + token_client.transfer(&sponsor, env.current_contract_address(), &amount); + + let contribution_key = DataKey::Contribution(milestone_id, milestone.contributor_count); + env.storage() + .persistent() + .set(&contribution_key, &Contribution { sponsor, amount }); + extend_ttl(&env, &contribution_key); + + // New funds arrive unallocated: the pool's total *and* its + // unallocated remainder both grow by exactly the contribution, so + // a later proportional refund treats them like any other share. + milestone.total_budget += amount; + milestone.remaining_budget += amount; + milestone.contributor_count += 1; + env.storage().persistent().set(&mkey, &milestone); + extend_ttl(&env, &mkey); + + Ok(()) + } + /// Admin-only: reserves `amount` of the milestone's remaining budget for /// `issue_id`. Rejects if the issue is already allocated, the milestone /// is closed, or `amount` exceeds the remaining (unallocated) budget. @@ -183,7 +266,12 @@ impl MilestonesContract { } /// Admin-only: closes the milestone and refunds any unallocated budget - /// back to the sponsor (e.g. release cancelled with issues remaining). + /// back to its contributors, in proportion to what each one put in + /// (e.g. release cancelled with issues remaining). A single-sponsor + /// milestone is the degenerate case: the whole remainder goes back to + /// the one sponsor, exactly as before this contract supported + /// crowdfunding. See `refund_remaining_budget` and + /// `docs/milestones-crowdfunding-design.md`. pub fn cancel_milestone(env: Env, milestone_id: u64) -> Result<(), Error> { require_admin(&env)?.require_auth(); @@ -199,12 +287,7 @@ impl MilestonesContract { } if milestone.remaining_budget > 0 { - let token_client = token::Client::new(&env, &milestone.token); - token_client.transfer( - &env.current_contract_address(), - &milestone.sponsor, - &milestone.remaining_budget, - ); + refund_remaining_budget(&env, milestone_id, &milestone)?; milestone.remaining_budget = 0; } milestone.closed = true; @@ -230,6 +313,22 @@ impl MilestonesContract { .get(&DataKey::IssueStatus(milestone_id, issue_id)) .ok_or(Error::IssueNotAllocated) } + + /// Returns the `index`-th contribution recorded for `milestone_id` + /// (`0` is always the original `create_milestone` caller; subsequent + /// indices are `contribute` calls in the order they were accepted), + /// letting off-chain callers enumerate the full contribution ledger + /// via `0..milestone.contributor_count`. + pub fn get_contribution( + env: Env, + milestone_id: u64, + index: u32, + ) -> Result { + env.storage() + .persistent() + .get(&DataKey::Contribution(milestone_id, index)) + .ok_or(Error::MilestoneNotFound) + } } struct Payouts { @@ -303,6 +402,83 @@ fn compute_split( Ok(Payouts { fee, shares }) } +/// Pays each contributor their share of `milestone.remaining_budget` (the +/// unallocated remainder of the pool), computed as +/// `remaining_budget * contribution.amount / total_budget` — i.e. in +/// proportion to what each sponsor actually put in, not to any nominal +/// split of the original deposit. Because the contribution ledger is +/// append-only and `total_budget` / `remaining_budget` are maintained +/// additively, each sponsor's share is a fixed fraction of the pool no +/// matter how many intervening `allocate` / `release_issue` (and, once the +/// deallocate issue lands, `deallocate`) calls happened between the +/// deposits and this refund — the remainder to return is simply sliced by +/// those fixed fractions at refund time. +/// +/// Uses largest-remainder rounding (the same invariant as +/// `compute_split`): every share is floored first, then the remaining dust +/// (always strictly less than `contributor_count` units) is granted one +/// unit at a time to the entry with the largest fractional remainder, +/// tie-broken by contribution index so the outcome is deterministic and +/// independent of anything a caller controls. The full `remaining_budget` +/// is returned, so no dust is stranded in the contract. A single-sponsor +/// milestone is the degenerate case: contribution 0's amount equals +/// `total_budget`, so the share is exactly `remaining_budget`. +fn refund_remaining_budget( + env: &Env, + milestone_id: u64, + milestone: &Milestone, +) -> Result<(), Error> { + let remaining = milestone.remaining_budget; + if remaining <= 0 { + return Ok(()); + } + + let token_client = token::Client::new(env, &milestone.token); + let contract_address = env.current_contract_address(); + + let mut shares: Vec<(Address, i128)> = Vec::new(env); + let mut remainders: Vec = Vec::new(env); + let mut allocated: i128 = 0; + + for i in 0..milestone.contributor_count { + let contribution_key = DataKey::Contribution(milestone_id, i); + let contribution: Contribution = env.storage().persistent().get(&contribution_key).unwrap(); + let numerator = remaining * contribution.amount; + let share = numerator / milestone.total_budget; + let remainder = numerator % milestone.total_budget; + allocated += share; + shares.push_back((contribution.sponsor, share)); + remainders.push_back(remainder); + } + + let mut dust = remaining - allocated; + while dust > 0 { + // Strict `>` keeps the lowest index on ties, so the dust award is + // fully deterministic (the ledger's append order, not caller input). + let mut best_index: u32 = 0; + let mut best_remainder: i128 = -1; + for (i, remainder) in remainders.iter().enumerate() { + if remainder > best_remainder { + best_index = i as u32; + best_remainder = remainder; + } + } + + let (recipient, share) = shares.get(best_index).unwrap(); + shares.set(best_index, (recipient, share + 1)); + remainders.set(best_index, -1); + dust -= 1; + } + + for (recipient, share) in shares.iter() { + if share > 0 { + token_client.transfer(&contract_address, &recipient, &share); + } + } + + Ok(()) +} + fn require_admin(env: &Env) -> Result { env.storage() .instance() diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 69aaa4f..1a1c5fa 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -248,3 +248,229 @@ fn test_cancel_milestone_requires_admin_auth() { let result = client.try_cancel_milestone(&9u64); assert!(result.is_err()); } + +// --------------------------------------------------------------------------- +// Multi-sponsor crowdfunding (#58) +// --------------------------------------------------------------------------- + +#[test] +fn test_multi_sponsor_milestone_proportional_refund_after_partial_allocation() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + + let sponsor_a = Address::generate(&env); + let sponsor_b = Address::generate(&env); + // Each sponsor is minted exactly their contribution, so post-refund + // balances directly show what came back with no other funds involved. + asset_client.mint(&sponsor_a, &700i128); + asset_client.mint(&sponsor_b, &300i128); + + // Sponsor A opens the milestone with 700; sponsor B co-funds with 300. + client.create_milestone(&50u64, &sponsor_a, &token_addr, &700i128); + client.contribute(&50u64, &sponsor_b, &300i128); + + let milestone = client.get_milestone(&50u64); + assert_eq!(milestone.total_budget, 1_000i128); + assert_eq!(milestone.remaining_budget, 1_000i128); + assert_eq!(milestone.contributor_count, 2); + + // 400 of the 1000 is allocated to a real issue and released (the fee + // and recipient payout draw on the allocation, so `remaining_budget` + // stays at 600). + client.allocate(&50u64, &501u64, &400i128); + let maintainer = Address::generate(&env); + client.release_issue(&50u64, &501u64, &vec![&env, (maintainer, 10_000u32)]); + + client.cancel_milestone(&50u64); + + // Remaining budget is 600; it is refunded in proportion to what each + // sponsor contributed (70/30 of the *unspent* remainder) — not an even + // split of the 600, and not 70/30 of the original 1000 nominal total. + assert_eq!(token_client.balance(&sponsor_a), 420i128); // 70% of 600 + assert_eq!(token_client.balance(&sponsor_b), 180i128); // 30% of 600 + assert_eq!(client.get_milestone(&50u64).remaining_budget, 0); +} + +#[test] +fn test_multi_sponsor_refund_rounds_dust_by_largest_remainder() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, token_client) = create_token(&env, &token_admin); + + let a = Address::generate(&env); + let b = Address::generate(&env); + let c = Address::generate(&env); + asset_client.mint(&a, &3i128); + asset_client.mint(&b, &3i128); + asset_client.mint(&c, &4i128); + + client.create_milestone(&51u64, &a, &token_addr, &3i128); + client.contribute(&51u64, &b, &3i128); + client.contribute(&51u64, &c, &4i128); + + // Leave 7 of the 10 unallocated: 7*3/10 = 2 (rem 1), 7*3/10 = 2 (rem 1), + // 7*4/10 = 2 (rem 8) -> the single dust unit goes to the largest + // remainder, i.e. c, so the refund is 2 + 2 + 3 = 7 and nothing is + // stranded in the contract. + client.allocate(&51u64, &511u64, &3i128); + client.cancel_milestone(&51u64); + + assert_eq!(token_client.balance(&a), 2i128); + assert_eq!(token_client.balance(&b), 2i128); + assert_eq!(token_client.balance(&c), 3i128); +} + +#[test] +fn test_contribute_grows_pool_and_remaining_budget() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + + client.create_milestone(&52u64, &alice, &token_addr, &1_000i128); + // The same sponsor (or anyone) can top up: new funds arrive unallocated, + // so both totals grow together. + client.contribute(&52u64, &alice, &500i128); + + let milestone = client.get_milestone(&52u64); + assert_eq!(milestone.total_budget, 1_500i128); + assert_eq!(milestone.remaining_budget, 1_500i128); + assert_eq!(milestone.contributor_count, 2); +} + +#[test] +fn test_contribute_requires_sponsor_auth() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + client.create_milestone(&53u64, &alice, &token_addr, &5_000i128); + + // No auth provided for bob's contribution. + env.set_auths(&[]); + let result = client.try_contribute(&53u64, &bob, &5_000i128); + assert!(result.is_err()); +} + +#[test] +fn test_contribute_rejects_invalid_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + client.create_milestone(&54u64, &alice, &token_addr, &5_000i128); + + let err = client.try_contribute(&54u64, &bob, &0i128); + assert_eq!(err, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_contribute_rejects_unknown_milestone() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let bob = Address::generate(&env); + let err = client.try_contribute(&999u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::MilestoneNotFound))); +} + +#[test] +fn test_contribute_rejects_after_closed() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + client.create_milestone(&55u64, &alice, &token_addr, &5_000i128); + client.cancel_milestone(&55u64); + + let err = client.try_contribute(&55u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::MilestoneClosed))); +} + +#[test] +fn test_contribute_rejects_beyond_max_sponsors() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + asset_client.mint(&alice, &100_000i128); + client.create_milestone(&56u64, &alice, &token_addr, &1_000i128); + + // 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) { + 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 + ); + + // The 21st distinct contribution is rejected. + let one_too_many = Address::generate(&env); + asset_client.mint(&one_too_many, &1_000i128); + let err = client.try_contribute(&56u64, &one_too_many, &1_000i128); + assert_eq!(err, Err(Ok(Error::TooManySponsors))); +} + +#[test] +fn test_get_contribution_enumerates_each_contributor() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_admin = Address::generate(&env); + let (token_addr, asset_client, _token_client) = create_token(&env, &token_admin); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + client.create_milestone(&57u64, &alice, &token_addr, &4_000i128); + client.contribute(&57u64, &bob, &6_000i128); + + let c0 = client.get_contribution(&57u64, &0u32); + let c1 = client.get_contribution(&57u64, &1u32); + assert_eq!(c0.sponsor, alice); + assert_eq!(c0.amount, 4_000i128); + assert_eq!(c1.sponsor, bob); + assert_eq!(c1.amount, 6_000i128); + + let err = client.try_get_contribution(&57u64, &2u32); + assert_eq!(err, Err(Ok(Error::MilestoneNotFound))); +} diff --git a/contracts/milestones/src/types.rs b/contracts/milestones/src/types.rs index 05ecbba..1bf17a9 100644 --- a/contracts/milestones/src/types.rs +++ b/contracts/milestones/src/types.rs @@ -1,21 +1,49 @@ use soroban_sdk::{contracttype, Address, Map}; -/// A milestone pools a sponsor's lump-sum budget across several issues that -/// belong to the same release. Each issue is allocated a slice of the -/// budget up front; as issues resolve, their allocation is paid out and -/// deducted from `remaining_budget`. +/// A milestone pools one or more sponsors' contributions into a lump-sum +/// budget shared across several issues that belong to the same release. +/// Each issue is allocated a slice of the budget up front; as issues +/// resolve, their allocation is paid out and deducted from the pool. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Milestone { + /// The original funder (the address that called `create_milestone`). + /// Retained for backward compatibility with the single-sponsor API; it + /// is always identical to contribution index `0` in the contribution + /// ledger and never changes, so it cannot drift out of sync. pub sponsor: Address, pub token: Address, + /// Running sum of every accepted contribution (starts at the + /// `create_milestone` deposit, grows with each `contribute` call). pub total_budget: i128, + /// Unallocated remainder of the pool. Starts equal to `total_budget`, + /// shrinks with each `allocate`, and (as of the future deallocate + /// issue) can grow again — it is the amount a `cancel_milestone` + /// refund returns to contributors, proportionally. pub remaining_budget: i128, pub created_at: u64, pub closed: bool, /// issue_id -> allocated amount (0 once released and removed from the /// "open" set is not necessary; we track release via `IssueStatus`). pub allocations: Map, + /// Number of distinct contributions recorded (`1` for a single-sponsor + /// milestone); enumerate the ledger via + /// `get_contribution(0..contributor_count)`. + pub contributor_count: u32, +} + +/// One sponsor's contribution toward a (possibly crowdfunded) milestone. +/// Stored under its own `DataKey::Contribution(milestone_id, index)` +/// entry rather than inline in a `Vec` on `Milestone` itself, mirroring +/// `escrow::Contribution` and `maintenance-pool::Deposit` — keeps each +/// storage entry small and bounded instead of one growing collection that +/// has to be read/written in full on every access. See +/// `docs/milestones-crowdfunding-design.md`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Contribution { + pub sponsor: Address, + pub amount: i128, } #[contracttype] @@ -32,5 +60,6 @@ pub enum DataKey { Treasury, FeeBps, Milestone(u64), - IssueStatus(u64, u64), // (milestone_id, issue_id) + IssueStatus(u64, u64), // (milestone_id, issue_id) + Contribution(u64, u32), // (milestone_id, contribution_index) } diff --git a/docs/milestones-crowdfunding-design.md b/docs/milestones-crowdfunding-design.md new file mode 100644 index 0000000..470808c --- /dev/null +++ b/docs/milestones-crowdfunding-design.md @@ -0,0 +1,158 @@ +# Multi-sponsor crowdfunding for `milestones`: contribution ledger and proportional refund + +Focused analysis for [#58](https://github.com/MergeFi/contracts/issues/58). +Before this change, `milestones::create_milestone` accepted exactly one +`sponsor: Address` and rejected a second call against the same +`milestone_id` outright, so a release's total budget could only ever come +from a single sponsor's wallet. This documents the design chosen to close +that gap. It is deliberately shaped like the escrow crowdfunding change +([#57](https://github.com/MergeFi/contracts/issues/57), see +`docs/escrow-crowdfunding-design.md`) so the two contracts share one +mental model and, eventually, one `Contribution` type if the +shared-crate effort (#16) lands — but the refund math here is +fundamentally different, because a milestone's budget is *partially +consumed over time* before any refund can happen. + +## The structural difference from escrow + +Escrow's `refund` returns the *entire* escrowed amount, so each +contributor gets back exactly the amount they put in — no proportional +math, ever. A milestone's refund, by contrast, returns only +`remaining_budget` — the unallocated leftover after some slices have been +`allocate`d (and, in future, `deallocate`d) and possibly already +`release_issue`d to real contributors. The question is: **which sponsor +gets how much of the leftover?** + +The answer implemented here: each sponsor's share is a *fixed fraction of +the pool*, determined at deposit time — `contribution.amount / +total_budget` — and the leftover is sliced by those same fractions at +refund time: + +``` +share_i = remaining_budget * contribution_i.amount / total_budget +``` + +Because the contribution ledger is append-only (`create_milestone` opens +with contribution index 0, every later `contribute` appends at the next +index) and `total_budget` / `remaining_budget` are maintained +additively, each sponsor's fraction is constant for the life of the +milestone. That is what makes this correct *across* an arbitrary number +of intervening `allocate` / `release_issue` (and future `deallocate`) +calls: the refund formula never depends on *when* the leftover was +computed, only on the fixed ledger, so it cannot drift out of +proportionality no matter how the pool shrank or grew between deposit and +cancellation. The acceptance scenario pins this exactly: A puts in 700, +B puts in 300, 400 is allocated and released, and the 600 leftover comes +back as 420 / 180 (70/30 of the *unspent* remainder) — not an even split +of the 600, and not 70/30 of the nominal 1000. + +### Rounding: largest-remainder, same invariant as `compute_split` + +`remaining_budget * contribution.amount` rarely divides `total_budget` +evenly, so naive integer division would strand dust in the contract. +`refund_remaining_budget` uses the same largest-remainder invariant as +`compute_split`: every share is floored first, then the remaining dust +(provably strictly less than `contributor_count` units, since the +contribution amounts sum to `total_budget`) is granted one unit at a +time to the entry with the largest fractional remainder, tie-broken by +contribution index — the ledger's append order — so the outcome is fully +deterministic and independent of anything a caller controls (mirroring +the adversarial-ordering fix in `compute_split`). The full +`remaining_budget` is returned; no dust is stranded. A single-sponsor +milestone is the degenerate case: contribution 0's amount equals +`total_budget`, so its share is exactly `remaining_budget` — behavior is +unchanged from before this change. + +## Contribution model: `create_milestone` creates, `contribute` appends + +Identical shape to escrow (#57), for the same reasons: + +- **`create_milestone` keeps its exact signature and behavior.** It is + the create half; the original funder is recorded as contribution index + `0`. A second `create_milestone` on the same `milestone_id` is still + rejected, so existing single-sponsor integrations are untouched. +- **`contribute(env, milestone_id, sponsor, amount)` is the new append + half.** It takes no `token` parameter — it reuses the token already + recorded on the milestone, so a top-up can never silently use a + different asset than the original funder intended, and no + `TokenMismatch`-style error is needed. +- **`Milestone.sponsor` is retained** (the original funder, always equal + to contribution index 0) for backward compatibility with the public + `get_milestone` view; it is set once at creation and never changes, so + it cannot drift from the ledger. +- **New contributions grow `remaining_budget` as well as + `total_budget`.** Money arrives unallocated — a milestone pool is "fully + unallocated at deposit", so a top-up adds to both the pool total and + the unallocated remainder. (This is the one way the milestone model + differs mechanically from escrow's `contribute`, which grows only + `escrow.amount`.) No separate "target/goal" field was introduced, for + the same reason as escrow: there is no on-chain concept of "fully + funded", `allocate` just draws down whatever has accumulated. + +## `MAX_SPONSORS`, storage shape + +Contributions are stored as separate persistent entries, +`DataKey::Contribution(milestone_id, index)` → `Contribution { sponsor, +amount }`, one per sponsor — the same shape `escrow::Contribution` and +`maintenance-pool::Deposit` already use, rather than a growing `Vec` +inline on `Milestone`. Two reasons, unchanged from escrow: + +1. **Bounded storage entries.** A `Vec` on `Milestone` would make every + read/write of the milestone record load the entire contribution + history, even for operations (`allocate`, `release_issue`) that never + look at it. +2. **No unbounded growth.** `MAX_SPONSORS` (20) caps `contributor_count`, + so `cancel_milestone`'s (and any future timeout wind-down's) + per-contributor loop — and, critically, the refund's dust distribution + — is bounded by a small constant regardless of how popular a release + gets. + +## Authorization + +`allocate` / `release_issue` / `cancel_milestone` remain admin-only, +exactly as before — how many sponsors funded the milestone is irrelevant +to who may allocate, release, or cancel it. `create_milestone` / +`contribute` require the contributor's own `require_auth()`, matching the +escrow rule that a backend key can never move a sponsor's funds *into* a +contract on their behalf. + +The issue asks for an explicit decision on future *sponsor-authorized* +actions (e.g. a sponsor-triggered timeout recovery per the companion +escape-hatch issue). Decision: **reuse the escrow rule verbatim — any +current contributor may act, not unanimous and not contribution-weighted +consent.** The reasoning transfers directly: a sponsor-triggered recovery +only ever returns each contributor's own proportional share to them; it +cannot redirect anyone's money or change anyone's fraction, so a single +contributor acting unilaterally needs no weighted-vote machinery, and the +admin's independent cancel path remains the escape hatch if that action +was unwarranted. The per-contributor loop this requires is exactly the +one `cancel_milestone` already runs. + +## Sequencing decision (relative to the companion issues) + +This PR lands **before** both the "no deallocate/reallocate" issue and +the "no timeout escape hatch" issue, deliberately: + +- The proportional-refund logic is extracted into one private helper, + `refund_remaining_budget(env, milestone_id, milestone)`. The timeout + escape-hatch issue can call that same helper from its wind-down path + with zero retrofitting — this is the "implement multi-sponsor first" + scenario the issue predicted would be simpler, and it is. +- The deallocate/reallocate issue only ever *changes* + `remaining_budget` (growing it back after a release is unwound). It + does not touch the contribution ledger or anyone's fixed fraction, so + the proportional refund formula stays correct through deallocate + cycles with no changes to `refund_remaining_budget` itself — this is + the property the "across allocate/deallocate cycles" acceptance + criterion in #58 is about, and it holds by construction here. +- Landing multi-sponsor first also means neither companion issue ever + has to decide "which single sponsor gets the refund" — that question is + already answered (proportionally, to all of them) before they start. + +The one interaction to keep in mind when the escape-hatch issue lands: +its permissionless/timeout path must apply the same `MAX_SPONSORS`-bounded +loop and the same largest-remainder dust rule as `cancel_milestone`, and +both paths must set `remaining_budget = 0` / `closed = true` in the same +way, so a milestone can never be wound down twice. If that issue instead +lands first, the proportional accounting designed here is the contract +its designs will have to retrofit against.