11//! MergeFi Milestone Funding Contract
22//!
3- //! A milestone pools a sponsor's lump-sum budget across multiple GitHub
4- //! issues that make up a release. The sponsor deposits once; the backend
5- //! oracle allocates slices of the budget to individual issues and later
6- //! releases each allocation (optionally split across a team) as issues are
7- //! merged, exactly like the escrow contract's `release`, but drawn from a
8- //! shared pool instead of a single-issue deposit.
3+ //! A milestone pools one or more sponsors' contributions into a lump-sum
4+ //! budget shared across multiple GitHub issues that make up a release.
5+ //! The first sponsor deposits to open the pool; the backend oracle
6+ //! allocates slices of the budget to individual issues and later releases
7+ //! each allocation (optionally split across a team) as issues are merged,
8+ //! exactly like the escrow contract's `release`, but drawn from a shared
9+ //! pool instead of a single-issue deposit. If the release is cancelled,
10+ //! the unallocated remainder is refunded to every contributor in
11+ //! proportion to what they put in.
912#![ no_std]
1013
1114mod error;
@@ -16,10 +19,17 @@ mod test;
1619
1720use error:: Error ;
1821use soroban_sdk:: { contract, contractimpl, token, Address , Env , Map , Vec } ;
19- use types:: { DataKey , IssueStatus , Milestone } ;
22+ use types:: { Contribution , DataKey , IssueStatus , Milestone } ;
2023
2124pub const BPS_DENOMINATOR : i128 = 10_000 ;
2225
26+ /// Maximum number of distinct contributions (sponsors) a single milestone
27+ /// can accumulate. Bounds the per-contributor loop in `cancel_milestone`
28+ /// (and any future timeout-triggered wind-down that reuses
29+ /// `refund_remaining_budget`) to a small, predictable constant regardless
30+ /// of how popular a release gets. See `docs/milestones-crowdfunding-design.md`.
31+ pub const MAX_SPONSORS : u32 = 20 ;
32+
2333#[ contract]
2434pub struct MilestonesContract ;
2535
@@ -49,8 +59,13 @@ impl MilestonesContract {
4959 Ok ( ( ) )
5060 }
5161
52- /// Sponsor deposits `total_budget` of `token` to open a new milestone
53- /// pool. Requires sponsor authorization.
62+ /// The original sponsor deposits `total_budget` of `token` to open a
63+ /// new milestone pool. Requires that sponsor's authorization. One
64+ /// milestone per `milestone_id` — a second `create_milestone` call on
65+ /// the same id is rejected (`IssueAlreadyAllocated`); every sponsor
66+ /// after the first uses `contribute` instead. See
67+ /// `docs/milestones-crowdfunding-design.md` for why creation and
68+ /// contribution are kept as two separate entrypoints.
5469 pub fn create_milestone (
5570 env : Env ,
5671 milestone_id : u64 ,
@@ -72,6 +87,18 @@ impl MilestonesContract {
7287 let token_client = token:: Client :: new ( & env, & token) ;
7388 token_client. transfer ( & sponsor, env. current_contract_address ( ) , & total_budget) ;
7489
90+ // The original funder is always contribution index 0, exactly like
91+ // `escrow::fund`; every later sponsor appends via `contribute`.
92+ let contribution_key = DataKey :: Contribution ( milestone_id, 0 ) ;
93+ env. storage ( ) . persistent ( ) . set (
94+ & contribution_key,
95+ & Contribution {
96+ sponsor : sponsor. clone ( ) ,
97+ amount : total_budget,
98+ } ,
99+ ) ;
100+ extend_ttl ( & env, & contribution_key) ;
101+
75102 let milestone = Milestone {
76103 sponsor,
77104 token,
@@ -80,12 +107,68 @@ impl MilestonesContract {
80107 created_at : env. ledger ( ) . timestamp ( ) ,
81108 closed : false ,
82109 allocations : Map :: new ( & env) ,
110+ contributor_count : 1 ,
83111 } ;
84112 env. storage ( ) . persistent ( ) . set ( & key, & milestone) ;
85113 extend_ttl ( & env, & key) ;
86114 Ok ( ( ) )
87115 }
88116
117+ /// Adds an additional sponsor's contribution to an already-created
118+ /// milestone, enabling crowdfunding: several sponsors can co-fund the
119+ /// same `milestone_id`. Requires the contributing sponsor's
120+ /// authorization. Uses the token already recorded on the milestone (no
121+ /// `token` parameter), so a top-up can never silently use a different
122+ /// asset than the original funder intended. Rejects `MilestoneNotFound`,
123+ /// `MilestoneClosed`, and `TooManySponsors` once `MAX_SPONSORS`
124+ /// contributions have already been recorded.
125+ pub fn contribute (
126+ env : Env ,
127+ milestone_id : u64 ,
128+ sponsor : Address ,
129+ amount : i128 ,
130+ ) -> Result < ( ) , Error > {
131+ sponsor. require_auth ( ) ;
132+
133+ if amount <= 0 {
134+ return Err ( Error :: InvalidAmount ) ;
135+ }
136+
137+ let mkey = DataKey :: Milestone ( milestone_id) ;
138+ let mut milestone: Milestone = env
139+ . storage ( )
140+ . persistent ( )
141+ . get ( & mkey)
142+ . ok_or ( Error :: MilestoneNotFound ) ?;
143+
144+ if milestone. closed {
145+ return Err ( Error :: MilestoneClosed ) ;
146+ }
147+ if milestone. contributor_count >= MAX_SPONSORS {
148+ return Err ( Error :: TooManySponsors ) ;
149+ }
150+
151+ let token_client = token:: Client :: new ( & env, & milestone. token ) ;
152+ token_client. transfer ( & sponsor, env. current_contract_address ( ) , & amount) ;
153+
154+ let contribution_key = DataKey :: Contribution ( milestone_id, milestone. contributor_count ) ;
155+ env. storage ( )
156+ . persistent ( )
157+ . set ( & contribution_key, & Contribution { sponsor, amount } ) ;
158+ extend_ttl ( & env, & contribution_key) ;
159+
160+ // New funds arrive unallocated: the pool's total *and* its
161+ // unallocated remainder both grow by exactly the contribution, so
162+ // a later proportional refund treats them like any other share.
163+ milestone. total_budget += amount;
164+ milestone. remaining_budget += amount;
165+ milestone. contributor_count += 1 ;
166+ env. storage ( ) . persistent ( ) . set ( & mkey, & milestone) ;
167+ extend_ttl ( & env, & mkey) ;
168+
169+ Ok ( ( ) )
170+ }
171+
89172 /// Admin-only: reserves `amount` of the milestone's remaining budget for
90173 /// `issue_id`. Rejects if the issue is already allocated, the milestone
91174 /// is closed, or `amount` exceeds the remaining (unallocated) budget.
@@ -189,7 +272,12 @@ impl MilestonesContract {
189272 }
190273
191274 /// Admin-only: closes the milestone and refunds any unallocated budget
192- /// back to the sponsor (e.g. release cancelled with issues remaining).
275+ /// back to its contributors, in proportion to what each one put in
276+ /// (e.g. release cancelled with issues remaining). A single-sponsor
277+ /// milestone is the degenerate case: the whole remainder goes back to
278+ /// the one sponsor, exactly as before this contract supported
279+ /// crowdfunding. See `refund_remaining_budget` and
280+ /// `docs/milestones-crowdfunding-design.md`.
193281 pub fn cancel_milestone ( env : Env , milestone_id : u64 ) -> Result < ( ) , Error > {
194282 require_admin ( & env) ?. require_auth ( ) ;
195283
@@ -205,12 +293,7 @@ impl MilestonesContract {
205293 }
206294
207295 if milestone. remaining_budget > 0 {
208- let token_client = token:: Client :: new ( & env, & milestone. token ) ;
209- token_client. transfer (
210- & env. current_contract_address ( ) ,
211- & milestone. sponsor ,
212- & milestone. remaining_budget ,
213- ) ;
296+ refund_remaining_budget ( & env, milestone_id, & milestone) ?;
214297 milestone. remaining_budget = 0 ;
215298 }
216299 milestone. closed = true ;
@@ -236,6 +319,22 @@ impl MilestonesContract {
236319 . get ( & DataKey :: IssueStatus ( milestone_id, issue_id) )
237320 . ok_or ( Error :: IssueNotAllocated )
238321 }
322+
323+ /// Returns the `index`-th contribution recorded for `milestone_id`
324+ /// (`0` is always the original `create_milestone` caller; subsequent
325+ /// indices are `contribute` calls in the order they were accepted),
326+ /// letting off-chain callers enumerate the full contribution ledger
327+ /// via `0..milestone.contributor_count`.
328+ pub fn get_contribution (
329+ env : Env ,
330+ milestone_id : u64 ,
331+ index : u32 ,
332+ ) -> Result < Contribution , Error > {
333+ env. storage ( )
334+ . persistent ( )
335+ . get ( & DataKey :: Contribution ( milestone_id, index) )
336+ . ok_or ( Error :: MilestoneNotFound )
337+ }
239338}
240339
241340struct Payouts {
@@ -309,6 +408,83 @@ fn compute_split(
309408 Ok ( Payouts { fee, shares } )
310409}
311410
411+ /// Pays each contributor their share of `milestone.remaining_budget` (the
412+ /// unallocated remainder of the pool), computed as
413+ /// `remaining_budget * contribution.amount / total_budget` — i.e. in
414+ /// proportion to what each sponsor actually put in, not to any nominal
415+ /// split of the original deposit. Because the contribution ledger is
416+ /// append-only and `total_budget` / `remaining_budget` are maintained
417+ /// additively, each sponsor's share is a fixed fraction of the pool no
418+ /// matter how many intervening `allocate` / `release_issue` (and, once the
419+ /// deallocate issue lands, `deallocate`) calls happened between the
420+ /// deposits and this refund — the remainder to return is simply sliced by
421+ /// those fixed fractions at refund time.
422+ ///
423+ /// Uses largest-remainder rounding (the same invariant as
424+ /// `compute_split`): every share is floored first, then the remaining dust
425+ /// (always strictly less than `contributor_count` units) is granted one
426+ /// unit at a time to the entry with the largest fractional remainder,
427+ /// tie-broken by contribution index so the outcome is deterministic and
428+ /// independent of anything a caller controls. The full `remaining_budget`
429+ /// is returned, so no dust is stranded in the contract. A single-sponsor
430+ /// milestone is the degenerate case: contribution 0's amount equals
431+ /// `total_budget`, so the share is exactly `remaining_budget`.
432+ fn refund_remaining_budget (
433+ env : & Env ,
434+ milestone_id : u64 ,
435+ milestone : & Milestone ,
436+ ) -> Result < ( ) , Error > {
437+ let remaining = milestone. remaining_budget ;
438+ if remaining <= 0 {
439+ return Ok ( ( ) ) ;
440+ }
441+
442+ let token_client = token:: Client :: new ( env, & milestone. token ) ;
443+ let contract_address = env. current_contract_address ( ) ;
444+
445+ let mut shares: Vec < ( Address , i128 ) > = Vec :: new ( env) ;
446+ let mut remainders: Vec < i128 > = Vec :: new ( env) ;
447+ let mut allocated: i128 = 0 ;
448+
449+ for i in 0 ..milestone. contributor_count {
450+ let contribution_key = DataKey :: Contribution ( milestone_id, i) ;
451+ let contribution: Contribution = env. storage ( ) . persistent ( ) . get ( & contribution_key) . unwrap ( ) ;
452+ let numerator = remaining * contribution. amount ;
453+ let share = numerator / milestone. total_budget ;
454+ let remainder = numerator % milestone. total_budget ;
455+ allocated += share;
456+ shares. push_back ( ( contribution. sponsor , share) ) ;
457+ remainders. push_back ( remainder) ;
458+ }
459+
460+ let mut dust = remaining - allocated;
461+ while dust > 0 {
462+ // Strict `>` keeps the lowest index on ties, so the dust award is
463+ // fully deterministic (the ledger's append order, not caller input).
464+ let mut best_index: u32 = 0 ;
465+ let mut best_remainder: i128 = -1 ;
466+ for ( i, remainder) in remainders. iter ( ) . enumerate ( ) {
467+ if remainder > best_remainder {
468+ best_index = i as u32 ;
469+ best_remainder = remainder;
470+ }
471+ }
472+
473+ let ( recipient, share) = shares. get ( best_index) . unwrap ( ) ;
474+ shares. set ( best_index, ( recipient, share + 1 ) ) ;
475+ remainders. set ( best_index, -1 ) ;
476+ dust -= 1 ;
477+ }
478+
479+ for ( recipient, share) in shares. iter ( ) {
480+ if share > 0 {
481+ token_client. transfer ( & contract_address, & recipient, & share) ;
482+ }
483+ }
484+
485+ Ok ( ( ) )
486+ }
487+
312488fn require_admin ( env : & Env ) -> Result < Address , Error > {
313489 mergefi_common:: require_admin :: < DataKey > ( env) . ok_or ( Error :: NotInitialized )
314490}
0 commit comments