Skip to content

Commit 5ec12f0

Browse files
authored
Merge pull request #66 from ZuLu0890/feat/milestones-crowdfunding
feat(milestones): multi-sponsor crowdfunding with proportional refund on cancel (#58)
2 parents a32c1f7 + 7b8b301 commit 5ec12f0

6 files changed

Lines changed: 652 additions & 32 deletions

File tree

README.md

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,33 @@ Lump-sum budget shared across the issues in a release.
207207
```rust
208208
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
209209
fn create_milestone(env, milestone_id: u64, sponsor: Address, token: Address, total_budget: i128) -> Result<(), Error>;
210+
fn contribute(env, milestone_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>;
210211
fn allocate(env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error>;
211212
fn release_issue(env, milestone_id: u64, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error>;
212213
fn cancel_milestone(env, milestone_id: u64) -> Result<(), Error>;
213214
fn get_milestone(env, milestone_id: u64) -> Result<Milestone, Error>;
214215
fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result<IssueStatus, Error>;
216+
fn get_contribution(env, milestone_id: u64, index: u32) -> Result<Contribution, Error>;
215217
```
216218

217-
- `create_milestone`: sponsor deposits `total_budget` once; the pool
218-
starts fully unallocated (`remaining_budget == total_budget`).
219+
- `create_milestone`: the original sponsor deposits `total_budget` once;
220+
the pool starts fully unallocated (`remaining_budget == total_budget`).
221+
One milestone per `milestone_id` — a second `create_milestone` on the
222+
same id is rejected; every sponsor after the first uses `contribute`
223+
instead.
224+
- `contribute`: `sponsor.require_auth()`. Adds an additional sponsor's
225+
funds to an already-`create_milestone`d pool — this is how crowdfunding
226+
a release across several sponsors works. Uses the token already
227+
recorded on the milestone (no `token` param, so a top-up can't silently
228+
use a different asset). New funds arrive unallocated, so both
229+
`total_budget` and `remaining_budget` grow by the contribution. Each
230+
contribution is recorded individually (`Contribution { sponsor, amount
231+
}`, queryable via `get_contribution`, with the original funder always
232+
at index 0) so a cancellation refund can return each sponsor's
233+
proportional share to their own address. Capped at `MAX_SPONSORS` (20)
234+
distinct contributions per milestone (`TooManySponsors` otherwise).
235+
Rejects `MilestoneClosed`. See
236+
`docs/milestones-crowdfunding-design.md` for the full design reasoning.
219237
- `allocate`: admin-only. Reserves a slice of `remaining_budget` for a
220238
specific `issue_id`. Over-allocating past what's left is rejected
221239
(`OverAllocation`); allocating an issue twice is rejected
@@ -224,9 +242,15 @@ fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result<IssueStatus
224242
`release`, but draws from the issue's pre-reserved allocation rather
225243
than a fresh deposit. Rejects double release (`IssueAlreadyReleased`).
226244
- `cancel_milestone`: admin-only. Refunds whatever is left in
227-
`remaining_budget` (i.e. never allocated) back to the sponsor and
228-
closes the milestone; already-released issues are unaffected since
229-
their funds already left the contract.
245+
`remaining_budget` (i.e. never allocated) back to the contributors **in
246+
proportion to what each one put in** — `remaining_budget *
247+
contribution / total_budget`, with largest-remainder rounding so no
248+
dust is stranded — and closes the milestone; already-released issues
249+
are unaffected since their funds already left the contract. A
250+
single-sponsor milestone is the degenerate case (the whole remainder
251+
goes back to the one sponsor, as before). See
252+
`docs/milestones-crowdfunding-design.md` for the proportional
253+
accounting and why it stays correct across allocate/release cycles.
230254

231255
### 3. `contracts/maintenance-pool``mergefi-maintenance-pool`
232256

@@ -274,13 +298,18 @@ pub struct Contribution {
274298

275299
// milestones
276300
pub struct Milestone {
277-
pub sponsor: Address,
301+
pub sponsor: Address, // original funder; always contribution index 0
278302
pub token: Address,
279-
pub total_budget: i128,
280-
pub remaining_budget: i128,
303+
pub total_budget: i128, // sum of every contribution accepted so far
304+
pub remaining_budget: i128, // unallocated remainder, refunded on cancel
281305
pub created_at: u64,
282306
pub closed: bool,
283307
pub allocations: Map<u64, i128>, // issue_id -> allocated amount
308+
pub contributor_count: u32, // enumerate via get_contribution(0..contributor_count)
309+
}
310+
pub struct Contribution {
311+
pub sponsor: Address,
312+
pub amount: i128,
284313
}
285314
pub enum IssueStatus { Allocated, Released }
286315

@@ -436,9 +465,10 @@ cargo build --target wasm32v1-none --release \
436465
-p mergefi-escrow -p mergefi-milestones -p mergefi-maintenance-pool
437466
```
438467

439-
Verified in this session: `cargo test --workspace`**34/34 tests pass**
440-
(17 escrow, 10 milestones, 7 maintenance-pool, including the
441-
access-control boundary matrix added in #30) on the native target using
468+
Verified in this session: `cargo test --workspace`**54/54 tests pass**
469+
(28 escrow, 19 milestones, 7 maintenance-pool, including the
470+
access-control boundary matrix added in #30 and the multi-sponsor
471+
crowdfunding tests added in #57/#58) on the native target using
442472
`soroban_sdk::testutils` (`Env::default()`, `Address::generate`,
443473
`mock_all_auths`, `register_stellar_asset_contract_v2` for a test token).
444474
The `wasm32v1-none` release build was also verified — all three contracts

contracts/milestones/src/error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ pub enum Error {
1616
InvalidAmount = 10,
1717
InvalidFee = 11,
1818
MilestoneClosed = 12,
19+
TooManySponsors = 13,
1920
}

contracts/milestones/src/lib.rs

Lines changed: 192 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
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

1114
mod error;
@@ -16,10 +19,17 @@ mod test;
1619

1720
use error::Error;
1821
use soroban_sdk::{contract, contractimpl, token, Address, Env, Map, Vec};
19-
use types::{DataKey, IssueStatus, Milestone};
22+
use types::{Contribution, DataKey, IssueStatus, Milestone};
2023

2124
pub 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]
2434
pub 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

241340
struct 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+
312488
fn require_admin(env: &Env) -> Result<Address, Error> {
313489
mergefi_common::require_admin::<DataKey>(env).ok_or(Error::NotInitialized)
314490
}

0 commit comments

Comments
 (0)