feat(milestones): multi-sponsor crowdfunding with proportional refund on cancel (#58) - #66
Merged
Conversation
… 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>
|
@ZuLu0890 is attempting to deploy a commit to the chonilius' projects Team on Vercel. A member of the Team first needs to authorize it. |
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #58
Summary
milestones::create_milestonehad the identical single-sponsor limitationthat
escrow::fundhad before #57: exactly onesponsor: Address, onetoken_client.transfer, and a one-shot storage gate making a second callagainst the same
milestone_idimpossible. A release's total budget couldonly 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
#57 — but with the structurally harder refund accounting that #58 calls
out: a milestone's budget is partially consumed over time via
allocate(and laterrelease_issue) before any refund can happen, so acancellation 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 analready-
create_milestoned pool. Notokenparameter (reuses the tokenrecorded on the milestone, so a top-up can't silently switch assets).
New funds arrive unallocated, so both
total_budgetandremaining_budgetgrow by the contribution.Contribution { sponsor, amount }recordsstored as separate persistent entries
DataKey::Contribution(milestone_id, index), with the original funder always at index 0 (mirroringescrow::Contribution/maintenance-pool::Deposit).Milestonegainscontributor_count; thesponsorfield is retained for backwardcompatibility (it is, and always will be, contribution index 0).
cancel_milestone— the unallocatedremainder is refunded to every contributor as
remaining_budget × contribution / total_budget, with largest-remainderrounding (same invariant as
compute_split, tie-broken by ledger indexso 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 thewhole remainder goes back to the one sponsor exactly as before.
get_contribution(milestone_id, index)— view getter so off-chaincallers can enumerate the ledger via
0..contributor_count, matchingescrow.
MAX_SPONSORS(20) — caps the per-contributor refund loop (and anyfuture timeout wind-down) to a small predictable constant;
TooManySponsorsbeyond 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 thosesame fractions at refund time. Because the contribution ledger is
append-only and
total_budget/remaining_budgetare maintainedadditively, the refund formula never depends on when the leftover was
computed — so it stays proportional across any number of intervening
allocate/release_issuecalls, and will keep working when thedeallocate/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:
refund_remaining_budget, so the timeout escape-hatch issue can reuseit 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).
remaining_budget; it doesn't touch the ledger or anyone's fraction, sothe refund formula stays correct through deallocate cycles with no
changes to the helper.
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, thesame largest-remainder dust rule, and the same
remaining_budget = 0/closed = truefinalization ascancel_milestone, so a milestone can never be wound down twice.Authorization (unchanged where it matters)
allocate/release_issue/cancel_milestoneremain admin-only,exactly as before — how many sponsors funded the milestone is
irrelevant to who may allocate, release, or cancel it.
create_milestone/contributerequire the contributor's ownrequire_auth(), matching the escrow rule that a backend key can nevermove a sponsor's funds into a contract on their behalf.
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 areunchanged (a second call on the same id is still rejected).
Milestone.sponsoris retained and stays equal to contribution index 0.cancel_milestonerefunds identically (the wholeremainder to the one sponsor); the pre-existing
test_cancel_milestone_refunds_remaining_budgetpasses unmodified.Tests
9 new tests in
contracts/milestones/src/test.rs(19 milestones teststotal):
test_multi_sponsor_milestone_proportional_refund_after_partial_allocation— the milestones: no crowdfunding support — proportional refund-on-cancel is structurally harder here than in escrow due to partial allocation over time #58 acceptance scenario (700/300 → allocate+release 400 → cancel →
420/180).
test_multi_sponsor_refund_rounds_dust_by_largest_remainder— 3/3/4contributions, 7 left over → 2/2/3, nothing stranded.
test_contribute_grows_pool_and_remaining_budgettest_contribute_requires_sponsor_authtest_contribute_rejects_invalid_amount/_unknown_milestone/_after_closed/_beyond_max_sponsorstest_get_contribution_enumerates_each_contributorCI (all green locally):
cargo fmt --check✅cargo clippy --workspace --all-targets -- -D warnings✅cargo test --workspace✅ — 54/54 (28 escrow, 19 milestones, 7maintenance-pool)
cargo build --target wasm32v1-none --release✅Files changed
contracts/milestones/src/lib.rs—contribute, proportionalcancel_milestone,get_contribution,refund_remaining_budget,MAX_SPONSORScontracts/milestones/src/types.rs—Contribution,contributor_count,DataKey::Contributioncontracts/milestones/src/error.rs—TooManySponsorscontracts/milestones/src/test.rs— 9 new testsdocs/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