Skip to content

feat(milestones): multi-sponsor crowdfunding with proportional refund on cancel (#58) - #66

Merged
chonilius merged 1 commit into
MergeFi:mainfrom
ZuLu0890:feat/milestones-crowdfunding
Aug 17, 2026
Merged

feat(milestones): multi-sponsor crowdfunding with proportional refund on cancel (#58)#66
chonilius merged 1 commit into
MergeFi:mainfrom
ZuLu0890:feat/milestones-crowdfunding

Conversation

@ZuLu0890

Copy link
Copy Markdown
Contributor

Closes #58

Summary

milestones::create_milestone had the identical single-sponsor limitation
that escrow::fund had before #57: exactly one sponsor: Address, one
token_client.transfer, and a one-shot storage gate making a second call
against the same milestone_id impossible. A release's total budget could
only ever come from one sponsor's wallet, with no on-chain path for
several companies to jointly back a release.

This PR brings milestones the same crowdfunding capability escrow got in
#57but with the structurally harder refund accounting that #58 calls
out
: a milestone's budget is partially consumed over time via
allocate (and later release_issue) before any refund can happen, so a
cancellation refund cannot be the escrow-style "pay everyone back exactly
what they put in". It has to slice the unallocated leftover back to each
contributor in proportion to what they contributed, correctly, no
matter how many intervening allocate/release calls happened.

What changed

  • contribute(env, milestone_id, sponsor, amount) — new entrypoint,
    mirroring escrow's contribute: appends a sponsor's funds to an
    already-create_milestoned pool. No token parameter (reuses the token
    recorded on the milestone, so a top-up can't silently switch assets).
    New funds arrive unallocated, so both total_budget and
    remaining_budget grow by the contribution.
  • Contribution ledgerContribution { sponsor, amount } records
    stored as separate persistent entries DataKey::Contribution(milestone_id, index), with the original funder always at index 0 (mirroring
    escrow::Contribution / maintenance-pool::Deposit). Milestone gains
    contributor_count; the sponsor field is retained for backward
    compatibility (it is, and always will be, contribution index 0).
  • Proportional refund in cancel_milestone — the unallocated
    remainder is refunded to every contributor as
    remaining_budget × contribution / total_budget, with largest-remainder
    rounding (same invariant as compute_split, tie-broken by ledger index
    so it's deterministic and caller-order-independent). The full remainder
    is returned; no dust is stranded. A single-sponsor milestone is the
    degenerate case: contribution 0's amount equals total_budget, so the
    whole remainder goes back to the one sponsor exactly as before.
  • get_contribution(milestone_id, index) — view getter so off-chain
    callers can enumerate the ledger via 0..contributor_count, matching
    escrow.
  • MAX_SPONSORS (20) — caps the per-contributor refund loop (and any
    future timeout wind-down) to a small predictable constant; TooManySponsors
    beyond it.
  • docs/milestones-crowdfunding-design.md — full design rationale.

Why proportional-to-contribution (and why it stays correct)

Each sponsor's share is a fixed fraction of the pool, fixed at deposit
time (contribution / total_budget), and the leftover is sliced by those
same fractions at refund time. Because the contribution ledger is
append-only and total_budget/remaining_budget are maintained
additively, the refund formula never depends on when the leftover was
computed — so it stays proportional across any number of intervening
allocate / release_issue calls, and will keep working when the
deallocate/reallocate issue lands (which only ever changes
remaining_budget, never anyone's fraction).

Acceptance scenario, verbatim from #58: sponsor A deposits 700,
sponsor B deposits 300 (total 1000); 400 is allocated to an issue and
released; cancel — remaining budget is 600; A receives 420 (70% of
600), B receives 180 (30% of 600) — not an even split of the 600 and
not 70/30 of the nominal 1000. Pinned by
test_multi_sponsor_milestone_proportional_refund_after_partial_allocation.

Sequencing decision (explicit, as #58 requires)

This PR intentionally lands before the companion "no deallocate /
reallocate" and "no timeout escape hatch" issues:

  • The proportional-refund logic lives in one private helper,
    refund_remaining_budget, so the timeout escape-hatch issue can reuse
    it from its wind-down path with zero retrofitting (the scenario milestones: no crowdfunding support — proportional refund-on-cancel is structurally harder here than in escrow due to partial allocation over time #58
    predicted would be simpler, and it is).
  • The deallocate/reallocate issue only ever changes
    remaining_budget; it doesn't touch the ledger or anyone's fraction, so
    the refund formula stays correct through deallocate cycles with no
    changes to the helper.
  • Landing first means neither companion issue ever has to decide "which
    single sponsor gets the refund" — that question is already answered
    (proportionally, to all of them).

The one contract the escape-hatch issue must honor when it lands: its
permissionless path must apply the same MAX_SPONSORS-bounded loop, the
same largest-remainder dust rule, and the same
remaining_budget = 0 / closed = true finalization as
cancel_milestone, so a milestone can never be wound down twice.

Authorization (unchanged where it matters)

  • 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.
  • Decision for any future sponsor-authorized action (e.g. a
    sponsor-triggered timeout recovery per the escape-hatch issue): reuse
    the escrow rule verbatim — any current contributor may act, not
    unanimous and not contribution-weighted consent. A sponsor-triggered
    recovery only ever returns each contributor's own proportional share to
    them; it cannot redirect funds or change anyone's fraction. Reasoning
    documented in docs/milestones-crowdfunding-design.md.

Backward compatibility

  • create_milestone's signature, behavior, and error semantics are
    unchanged (a second call on the same id is still rejected).
  • Milestone.sponsor is retained and stays equal to contribution index 0.
  • Single-sponsor cancel_milestone refunds identically (the whole
    remainder to the one sponsor); the pre-existing
    test_cancel_milestone_refunds_remaining_budget passes unmodified.

Tests

9 new tests in contracts/milestones/src/test.rs (19 milestones tests
total):

CI (all green locally):

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace ✅ — 54/54 (28 escrow, 19 milestones, 7
    maintenance-pool)
  • cargo build --target wasm32v1-none --release

Files changed

  • contracts/milestones/src/lib.rscontribute, proportional
    cancel_milestone, get_contribution, refund_remaining_budget,
    MAX_SPONSORS
  • contracts/milestones/src/types.rsContribution, contributor_count,
    DataKey::Contribution
  • contracts/milestones/src/error.rsTooManySponsors
  • contracts/milestones/src/test.rs — 9 new tests
  • docs/milestones-crowdfunding-design.md — new design doc (ledger,
    proportional-refund math across allocate/deallocate cycles, sequencing
    decision)
  • README.md — milestones API reference, data models, test counts

… on cancel (MergeFi#58)

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 <noreply@codebuff.com>
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

@ZuLu0890 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

@chonilius
chonilius merged commit 5ec12f0 into MergeFi:main Aug 17, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

milestones: no crowdfunding support — proportional refund-on-cancel is structurally harder here than in escrow due to partial allocation over time

2 participants