From 14b722217401ed2a740c9b4e83d9f68148e733dd Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:49:17 +0100 Subject: [PATCH 01/12] docs: document multi-sponsor crowdfunding design decisions for escrow (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focused design analysis for #57: the contribution model (fund creates, contribute appends), why refund needs exact per-contributor reimbursement rather than proportional splitting, the any-contributor extend_deadline semantics, and the MAX_SPONSORS bound rationale — written before the implementation that follows in subsequent commits. --- docs/escrow-crowdfunding-design.md | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/escrow-crowdfunding-design.md diff --git a/docs/escrow-crowdfunding-design.md b/docs/escrow-crowdfunding-design.md new file mode 100644 index 0000000..598b699 --- /dev/null +++ b/docs/escrow-crowdfunding-design.md @@ -0,0 +1,112 @@ +# Multi-sponsor crowdfunding for `escrow`: contribution model, refund, and `extend_deadline` + +Focused analysis for [#57](https://github.com/MergeFi/contracts/issues/57). +Before this change, `escrow::fund` accepted exactly one `sponsor: Address` +and rejected a second call against the same `issue_id` outright +(`AlreadyFunded`), so there was no on-chain way for more than one sponsor +to co-fund the same issue. This documents the design chosen to close that +gap, and why the alternatives considered were rejected. + +## Contribution model: `fund` creates, `contribute` appends + +Two shapes were considered for letting more than one sponsor put money +into the same `issue_id`: + +- **Overload `fund` to silently branch on whether the escrow already + exists** (create it on the first call, top it up on subsequent calls). + Rejected: it would either have to drop the existing `AlreadyFunded` + guard entirely (a behavior change for every existing single-sponsor + integration that relies on a second `fund` call being rejected), or + keep some other implicit signal to distinguish "first funder" from + "additional funder" inside one function — more surface area for a + caller to get wrong (e.g. accidentally omitting `deadline` on a + top-up call and having it silently ignored) for no real benefit over + just having two functions. +- **Two functions: `fund` (create) and `contribute` (append) — + implemented here.** `fund` keeps its exact existing behavior and + error semantics, including `AlreadyFunded` on a second call for the + same `issue_id`. `contribute(env, issue_id, sponsor, amount)` is the + new entrypoint every sponsor after the first uses; it takes no + `token`/`deadline` params at all — it reuses whatever's already + recorded on the escrow, so there's no possibility of a top-up + silently using a different token or deadline than the original + funder intended, and no new `TokenMismatch`-style error needed the + way `maintenance-pool::deposit` requires for its own multi-sponsor + case. + +No separate "target/goal amount" field was introduced. `escrow.amount` is +simply the running sum of every accepted contribution (starting with the +`fund` call, i.e. the original sponsor is contribution index `0`); there's +no on-chain concept of "fully funded" versus "partially funded" — `release` +pays out whatever has accumulated, same as today. This mirrors the fact +that the pre-existing single-sponsor design never had an upper amount cap +either. + +## Refund: exact reimbursement, not proportional splitting + +Each contribution is stored as its own `Contribution { sponsor, amount }` +record (`DataKey::Contribution(issue_id, index)`), a separate persistent +entry per contributor — the same shape `maintenance-pool::Deposit` already +uses, rather than one `Vec<(Address, i128)>` field inline on `Escrow`. +Two reasons: + +1. **Bounded storage entries.** A single growing `Vec` on `Escrow` means + every read/write of the escrow record has to load and re-serialize the + entire contribution history, even for operations (like `release`) that + don't need it at all. Per-index entries keep `Escrow` itself small and + let `refund`/`extend_deadline` read only what they need. +2. **No unbounded growth.** `MAX_SPONSORS` (20) caps `contributor_count`, + so `refund`'s and `extend_deadline`'s per-contributor loops are bounded + by a small constant regardless of how popular a bounty gets — directly + addressing the same resource concern #8/#9 raise for `recipients`/ + `allocations` elsewhere in this codebase. Twenty is generous enough for + any realistic crowdfunding scenario for a single GitHub issue while + keeping the worst-case loop (and its Stellar resource cost) small and + predictable. + +Because each contribution is recorded as an *exact* amount rather than a +percentage, `refund` needs no proportional-split math at all: it iterates +`0..contributor_count`, and pays each `Contribution.amount` back to that +same `Contribution.sponsor`, verbatim. There's no rounding/dust question +the way `compute_split`'s basis-point payouts have — the sum of what goes +back out is definitionally exactly the sum of what came in, split exactly +along the lines it arrived in. + +## `extend_deadline`: any current contributor, not unanimous or weighted consent + +Before this change, `extend_deadline` was gated by +`escrow.sponsor.require_auth()` — trivial with exactly one possible +sponsor. With potentially many contributors, three shapes were considered: + +- **Unanimous consent** (every contributor must co-sign). Rejected: adds + real coordination cost (gathering N on-chain signatures in one + transaction, or some multi-step approval flow that doesn't exist yet) + for a change that's Pareto-improving for the group in the common case + — see below. +- **Contribution-weighted consent** (e.g. majority-by-amount). Rejected: + meaningfully more state and logic (weighted vote tallying, a threshold + constant to pick and justify) for a decision that doesn't obviously + need it — extending the deadline doesn't redistribute anyone's money + or change anyone's share, so weighting by contribution size doesn't + protect against anything a simpler rule doesn't already cover. +- **Any current contributor may extend — implemented here.** The new + `caller: Address` parameter is checked against every recorded + `Contribution.sponsor` for that `issue_id`; if `caller` matches any of + them (and `caller.require_auth()` succeeds, so nobody can claim to be a + contributor they aren't), the extension is allowed. Reasoning: + extending only ever *delays* the point at which `refund`'s + permissionless path opens — it can never shorten it, redirect funds, or + change anyone's payout — and every contributor already staked into this + escrow overwhelmingly prefers "give the work more time to land and + trigger `release`" over "an earlier refund," since that's the entire + reason they contributed in the first place. A single contributor acting + unilaterally to give the group's shared bounty more time to succeed + isn't a scenario that needs the other contributors' explicit sign-off + the way, say, redirecting funds would. The admin's independent + early-refund path (`refund` before `deadline`, admin-only) remains + available as an escape hatch if an extension ever turns out to have + been unwarranted. + +This is a straightforward generalization of the existing single-sponsor +rule ("the sponsor can extend") to "any of the (now possibly several) +sponsors can extend," rather than a new, more restrictive mechanism. From b76f9832f0e22ce8c1abcb4f52a381b9abe9a71d Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:53:19 +0100 Subject: [PATCH 02/12] escrow: add contribution ledger data model (types, error variant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Contribution { sponsor, amount } and DataKey::Contribution(issue_id, index) as the per-contributor storage shape (#57), and a new TooManySponsors error for the cap added in a later commit. Escrow gains a contributor_count field; fund() is updated to set it to 1 for the first (and, until the next commit, still only) contributor. The contribution ledger itself isn't written yet — this is purely the data model addition. --- contracts/escrow/src/error.rs | 1 + contracts/escrow/src/lib.rs | 1 + contracts/escrow/src/types.rs | 15 +++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/contracts/escrow/src/error.rs b/contracts/escrow/src/error.rs index e96da7d..581ad25 100644 --- a/contracts/escrow/src/error.rs +++ b/contracts/escrow/src/error.rs @@ -17,4 +17,5 @@ pub enum Error { InsufficientBalance = 11, InvalidFee = 12, InvalidDeadline = 13, + TooManySponsors = 14, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index f0e9917..10f832f 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -90,6 +90,7 @@ impl EscrowContract { status: EscrowStatus::Funded, created_at: env.ledger().timestamp(), deadline, + contributor_count: 1, }; env.storage().persistent().set(&key, &escrow); extend_ttl(&env, &key); diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index e223b0c..a41f402 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -17,6 +17,20 @@ pub struct Escrow { pub status: EscrowStatus, pub created_at: u64, pub deadline: u64, + pub contributor_count: u32, +} + +/// One sponsor's contribution toward a (possibly crowdfunded) escrow. +/// Stored under its own `DataKey::Contribution(issue_id, index)` entry +/// rather than inline in a `Vec` on `Escrow` itself, mirroring +/// `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/escrow-crowdfunding-design.md`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Contribution { + pub sponsor: Address, + pub amount: i128, } #[contracttype] @@ -26,4 +40,5 @@ pub enum DataKey { Treasury, FeeBps, Escrow(u64), + Contribution(u64, u32), // (issue_id, contribution_index) } From a04710e34674633b0e895fb6acc6ec972c520d3c Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:54:31 +0100 Subject: [PATCH 03/12] escrow: record the original funder as contribution index 0 in fund() Adds MAX_SPONSORS (20) and starts writing to the contribution ledger: fund() now records its sponsor/amount as Contribution(issue_id, 0), treating the original funder uniformly as the first contributor rather than special-casing them relative to sponsors who join later via contribute() (added next). --- contracts/escrow/src/lib.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 10f832f..1790310 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -15,11 +15,17 @@ mod test; use error::Error; use soroban_sdk::{contract, contractimpl, token, Address, Env, Vec}; -use types::{DataKey, Escrow, EscrowStatus}; +use types::{Contribution, DataKey, Escrow, EscrowStatus}; /// Basis points denominator (100.00%). pub const BPS_DENOMINATOR: i128 = 10_000; +/// Maximum number of distinct contributions (sponsors) a single escrow can +/// accumulate. Bounds the per-contributor loops in `refund` and +/// `extend_deadline` to a small, predictable constant regardless of how +/// popular a bounty gets. See `docs/escrow-crowdfunding-design.md`. +pub const MAX_SPONSORS: u32 = 20; + #[contract] pub struct EscrowContract; @@ -83,6 +89,16 @@ impl EscrowContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&sponsor, env.current_contract_address(), &amount); + let contribution_key = DataKey::Contribution(issue_id, 0); + env.storage().persistent().set( + &contribution_key, + &Contribution { + sponsor: sponsor.clone(), + amount, + }, + ); + extend_ttl(&env, &contribution_key); + let escrow = Escrow { sponsor, token, From 33706ba3493be0dd13a4057780480b5919b17246 Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:55:19 +0100 Subject: [PATCH 04/12] escrow: add contribute() for additional sponsors to co-fund an escrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second half of the create/append split (#57): fund() remains the sole entrypoint that creates an escrow (unchanged AlreadyFunded guard); contribute() is the new entrypoint every sponsor after the first uses to add more funds, capped at MAX_SPONSORS distinct contributions per escrow. Not yet wired into refund/extend_deadline — those still only know about the original single sponsor field, updated in the next commits. --- contracts/escrow/src/lib.rs | 54 +++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1790310..c23cb12 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -114,6 +114,60 @@ impl EscrowContract { Ok(()) } + /// Adds an additional sponsor's contribution to an already-funded + /// escrow, enabling crowdfunding: several sponsors can co-fund the same + /// `issue_id`. Requires the contributing sponsor's authorization. Uses + /// the token already recorded on the escrow (no `token` parameter), so + /// a top-up can never silently use a different asset than the original + /// funder intended. Rejects `EscrowNotFound`, `AlreadyPaid`, + /// `AlreadyRefunded`, and `TooManySponsors` once `MAX_SPONSORS` + /// contributions have already been recorded. + pub fn contribute( + env: Env, + issue_id: u64, + sponsor: Address, + amount: i128, + ) -> Result<(), Error> { + sponsor.require_auth(); + + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + let key = DataKey::Escrow(issue_id); + let mut escrow: Escrow = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::EscrowNotFound)?; + + match escrow.status { + EscrowStatus::Paid => return Err(Error::AlreadyPaid), + EscrowStatus::Refunded => return Err(Error::AlreadyRefunded), + EscrowStatus::Funded => {} + } + + if escrow.contributor_count >= MAX_SPONSORS { + return Err(Error::TooManySponsors); + } + + let token_client = token::Client::new(&env, &escrow.token); + token_client.transfer(&sponsor, env.current_contract_address(), &amount); + + let contribution_key = DataKey::Contribution(issue_id, escrow.contributor_count); + env.storage() + .persistent() + .set(&contribution_key, &Contribution { sponsor, amount }); + extend_ttl(&env, &contribution_key); + + escrow.amount += amount; + escrow.contributor_count += 1; + env.storage().persistent().set(&key, &escrow); + extend_ttl(&env, &key); + + Ok(()) + } + /// Releases escrowed funds to one or more recipients. `recipients` is a /// list of (address, basis_points) pairs that must sum to exactly /// `BPS_DENOMINATOR` (10000 = 100%). A protocol fee (`fee_bps`, From f036aa880697a3480ef981ec752010b3a9f21bbc Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:56:16 +0100 Subject: [PATCH 05/12] escrow: refund iterates the contribution ledger instead of a single sponsor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refund() now pays every contributor exactly their own recorded amount (#57), rather than the full escrow.amount to a single escrow.sponsor. For a single-sponsor escrow this is exactly equivalent to the old behavior (contributor_count is always 1, so the loop runs once) — verified by the unmodified existing refund tests still passing. escrow. sponsor is still read by extend_deadline, updated in the next commit. --- contracts/escrow/src/lib.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index c23cb12..e6fd45e 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -213,8 +213,14 @@ impl EscrowContract { Ok(()) } - /// Refunds the sponsor. Callable by the admin at any time (e.g. issue - /// cancelled), or by anyone once the escrow's deadline has passed. + /// Refunds every contributor their own contributed amount, to their own + /// address — not just the full escrowed amount to a single sponsor. + /// Callable by the admin at any time (e.g. issue cancelled), or by + /// anyone once the escrow's deadline has passed. Because each + /// contribution is stored as an exact amount rather than a share, no + /// proportional-split math is needed: the sum refunded is exactly the + /// sum contributed, returned along the same lines it arrived in. See + /// `docs/escrow-crowdfunding-design.md`. pub fn refund(env: Env, issue_id: u64) -> Result<(), Error> { let key = DataKey::Escrow(issue_id); let mut escrow: Escrow = env @@ -237,11 +243,17 @@ impl EscrowContract { } let token_client = token::Client::new(&env, &escrow.token); - token_client.transfer( - &env.current_contract_address(), - &escrow.sponsor, - &escrow.amount, - ); + let contract_address = env.current_contract_address(); + for i in 0..escrow.contributor_count { + let contribution_key = DataKey::Contribution(issue_id, i); + let contribution: Contribution = + env.storage().persistent().get(&contribution_key).unwrap(); + token_client.transfer( + &contract_address, + &contribution.sponsor, + &contribution.amount, + ); + } escrow.status = EscrowStatus::Refunded; env.storage().persistent().set(&key, &escrow); From d878fc282d6e35ea1868cb081ed0bdce30c7dac4 Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:58:37 +0100 Subject: [PATCH 06/12] escrow: generalize extend_deadline to any contributor, drop the single sponsor field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking API change (#57): extend_deadline now takes an explicit caller parameter and accepts any address recorded as a contributor for that issue_id, rejecting non-contributors with Unauthorized — a direct generalization of the old escrow.sponsor.require_auth() rule to however many sponsors have now co-funded the escrow. With refund and extend_deadline both now working purely off the contribution ledger, Escrow's single sponsor field is redundant and removed; fund() no longer writes it. Updates the 4 existing extend_deadline tests for the new signature (auth still comes from the sponsor address, just passed explicitly now). --- contracts/escrow/src/lib.rs | 56 +++++++++++++++++++++++------------ contracts/escrow/src/test.rs | 12 ++++---- contracts/escrow/src/types.rs | 1 - 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index e6fd45e..3b26a97 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -90,17 +90,12 @@ impl EscrowContract { token_client.transfer(&sponsor, env.current_contract_address(), &amount); let contribution_key = DataKey::Contribution(issue_id, 0); - env.storage().persistent().set( - &contribution_key, - &Contribution { - sponsor: sponsor.clone(), - amount, - }, - ); + env.storage() + .persistent() + .set(&contribution_key, &Contribution { sponsor, amount }); extend_ttl(&env, &contribution_key); let escrow = Escrow { - sponsor, token, amount, status: EscrowStatus::Funded, @@ -262,15 +257,26 @@ impl EscrowContract { Ok(()) } - /// Sponsor-only: pushes `issue_id`'s deadline further into the future. - /// Lets a sponsor who wants more time before `refund`'s permissionless - /// path opens (e.g. a merge looks imminent right as the old deadline - /// approaches) signal that safely — `new_deadline` must be strictly - /// later than both the current stored deadline and the current ledger - /// time, so this can only ever delay the permissionless window, never - /// shorten it, and only the sponsor whose funds these are can call it. - /// See `docs/refund-permissionless-analysis.md` for the full reasoning. - pub fn extend_deadline(env: Env, issue_id: u64, new_deadline: u64) -> Result<(), Error> { + /// Pushes `issue_id`'s deadline further into the future. Callable by + /// `caller`, who must be *any* current contributor to this escrow (not + /// necessarily the original `fund` caller) — extending only ever + /// delays `refund`'s permissionless path, never redirects funds or + /// changes anyone's share, so it doesn't require unanimous or + /// contribution-weighted consent from every contributor. See + /// `docs/escrow-crowdfunding-design.md` for the full reasoning and + /// `docs/refund-permissionless-analysis.md` for the original + /// single-sponsor analysis this generalizes. `new_deadline` must be + /// strictly later than both the current stored deadline and the + /// current ledger time, so this can only ever delay the permissionless + /// window, never shorten it. + pub fn extend_deadline( + env: Env, + issue_id: u64, + caller: Address, + new_deadline: u64, + ) -> Result<(), Error> { + caller.require_auth(); + let key = DataKey::Escrow(issue_id); let mut escrow: Escrow = env .storage() @@ -278,14 +284,26 @@ impl EscrowContract { .get(&key) .ok_or(Error::EscrowNotFound)?; - escrow.sponsor.require_auth(); - match escrow.status { EscrowStatus::Paid => return Err(Error::AlreadyPaid), EscrowStatus::Refunded => return Err(Error::AlreadyRefunded), EscrowStatus::Funded => {} } + let mut is_contributor = false; + for i in 0..escrow.contributor_count { + let contribution_key = DataKey::Contribution(issue_id, i); + let contribution: Contribution = + env.storage().persistent().get(&contribution_key).unwrap(); + if contribution.sponsor == caller { + is_contributor = true; + break; + } + } + if !is_contributor { + return Err(Error::Unauthorized); + } + if new_deadline <= escrow.deadline || new_deadline <= env.ledger().timestamp() { return Err(Error::InvalidDeadline); } diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 5e41e70..70bbd71 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -397,7 +397,7 @@ fn test_extend_deadline_requires_sponsor_auth() { // Not even the admin can extend on the sponsor's behalf. env.set_auths(&[]); - let result = client.try_extend_deadline(&12u64, &500u64); + let result = client.try_extend_deadline(&12u64, &sponsor, &500u64); assert!(result.is_err()); } @@ -415,7 +415,7 @@ fn test_extend_deadline_pushes_out_the_permissionless_window() { env.ledger().set_timestamp(100); client.fund(&13u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); - client.extend_deadline(&13u64, &500u64); + client.extend_deadline(&13u64, &sponsor, &500u64); assert_eq!(client.get_escrow(&13u64).deadline, 500u64); // Old deadline (200) has now passed, but the extended one (500) hasn't: @@ -442,16 +442,16 @@ fn test_extend_deadline_rejects_non_increasing_deadline() { client.fund(&14u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); // Equal to the current deadline: rejected. - let err = client.try_extend_deadline(&14u64, &200u64); + let err = client.try_extend_deadline(&14u64, &sponsor, &200u64); assert_eq!(err, Err(Ok(Error::InvalidDeadline))); // Earlier than the current deadline: rejected. - let err = client.try_extend_deadline(&14u64, &150u64); + let err = client.try_extend_deadline(&14u64, &sponsor, &150u64); assert_eq!(err, Err(Ok(Error::InvalidDeadline))); // Later than the current deadline but not later than "now": rejected. env.ledger().set_timestamp(250); - let err = client.try_extend_deadline(&14u64, &201u64); + let err = client.try_extend_deadline(&14u64, &sponsor, &201u64); assert_eq!(err, Err(Ok(Error::InvalidDeadline))); } @@ -476,6 +476,6 @@ fn test_extend_deadline_rejects_after_paid_or_refunded() { ); client.release(&15u64, &vec![&env, (contributor, 10_000u32)]); - let err = client.try_extend_deadline(&15u64, &2_000u64); + let err = client.try_extend_deadline(&15u64, &sponsor, &2_000u64); assert_eq!(err, Err(Ok(Error::AlreadyPaid))); } diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index a41f402..11923e5 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -11,7 +11,6 @@ pub enum EscrowStatus { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Escrow { - pub sponsor: Address, pub token: Address, pub amount: i128, pub status: EscrowStatus, From 6282508eadd8e1bb15302b735331217269113f2e Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:59:30 +0100 Subject: [PATCH 07/12] escrow: add get_contribution view getter for enumerating an escrow's contributors Lets off-chain callers (mergefi-backend, indexers) enumerate the full contribution ledger for an issue_id via 0..escrow.contributor_count, mirroring the existing get_escrow/get_admin/get_treasury getter pattern. Completes the implementation surface of #57's design; remaining commits add test coverage and update docs. --- contracts/escrow/src/lib.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 3b26a97..d3fcceb 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -323,6 +323,18 @@ impl EscrowContract { .ok_or(Error::EscrowNotFound) } + /// Returns the `index`-th contribution recorded for `issue_id` (`0` is + /// always the original `fund` caller; subsequent indices are + /// `contribute` calls in the order they were accepted), letting + /// off-chain callers enumerate the full contribution ledger for an + /// escrow via `0..escrow.contributor_count`. + pub fn get_contribution(env: Env, issue_id: u64, index: u32) -> Result { + env.storage() + .persistent() + .get(&DataKey::Contribution(issue_id, index)) + .ok_or(Error::EscrowNotFound) + } + pub fn get_admin(env: Env) -> Result { env.storage() .instance() From 427987355916e6ad062cdae5540984788d1673a1 Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:00:57 +0100 Subject: [PATCH 08/12] escrow: update fund() doc comment to reflect the create/contribute split Small doc-only follow-up to the crowdfunding implementation (#57): fund()'s doc comment still described the old single-sponsor-only behavior; updates it to explain the AlreadyFunded/contribute split now that a second fund() call and a contribute() call mean different things. --- contracts/escrow/src/lib.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index d3fcceb..c9f95cb 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -64,9 +64,14 @@ impl EscrowContract { Ok(()) } - /// Sponsor deposits `amount` of `token` into escrow for `issue_id`. - /// Requires the sponsor's authorization. `deadline` is a unix timestamp - /// (ledger time) after which, if unpaid, the sponsor may reclaim funds. + /// Sponsor deposits `amount` of `token` into escrow for `issue_id`, + /// creating it. Requires the sponsor's authorization. `deadline` is a + /// unix timestamp (ledger time) after which, if unpaid, contributors + /// may reclaim their funds. One escrow per `issue_id` — a second `fund` + /// call on the same id is rejected (`AlreadyFunded`); every sponsor + /// after the first uses `contribute` instead. See + /// `docs/escrow-crowdfunding-design.md` for why creation and + /// contribution are kept as two separate entrypoints. pub fn fund( env: Env, issue_id: u64, From 75280804daf2c2f1cbca3acf5af88b8f10231d4e Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:02:46 +0100 Subject: [PATCH 09/12] test: cover multi-sponsor fund/refund and fund/release for escrow crowdfunding (#57) Adds the two core crowdfunding regression tests: three sponsors co-funding one issue_id with unequal amounts get back exactly their own contribution on refund (not an even split, not the full amount to one sponsor), and release() correctly pays out the combined total across all contributors, same fee math as a single-sponsor escrow. --- contracts/escrow/src/test.rs | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 70bbd71..1a0c8a8 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -479,3 +479,77 @@ fn test_extend_deadline_rejects_after_paid_or_refunded() { let err = client.try_extend_deadline(&15u64, &sponsor, &2_000u64); assert_eq!(err, Err(Ok(Error::AlreadyPaid))); } + +// --------------------------------------------------------------------------- +// Crowdfunding (#57) +// --------------------------------------------------------------------------- + +#[test] +fn test_multi_sponsor_refund_returns_exact_contributions_to_each_sponsor() { + 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); + let carol = Address::generate(&env); + // Each sponsor is minted exactly their contribution amount, so their + // post-refund balance is a direct check of "did the correct amount + // come back" with no other funds to obscure it. + asset_client.mint(&alice, &3_000i128); + asset_client.mint(&bob, &7_000i128); + asset_client.mint(&carol, &1_500i128); + + env.ledger().set_timestamp(100); + + // Three different sponsors co-fund the same issue with three different + // (deliberately unequal) amounts. + client.fund(&100u64, &alice, &token_addr, &3_000i128, &200u64); + client.contribute(&100u64, &bob, &7_000i128); + client.contribute(&100u64, &carol, &1_500i128); + + let escrow = client.get_escrow(&100u64); + assert_eq!(escrow.amount, 11_500i128); + assert_eq!(escrow.contributor_count, 3); + + // Past the deadline: permissionless refund. + env.ledger().set_timestamp(300); + env.set_auths(&[]); + client.refund(&100u64); + + // Each sponsor gets back exactly what they put in — not an even split + // (11_500 / 3) and not the full amount to only one of them. + assert_eq!(token_client.balance(&alice), 3_000i128); + assert_eq!(token_client.balance(&bob), 7_000i128); + assert_eq!(token_client.balance(&carol), 1_500i128); + assert_eq!(client.get_escrow(&100u64).status, EscrowStatus::Refunded); +} + +#[test] +fn test_multi_sponsor_release_pays_out_the_combined_total() { + 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.fund(&101u64, &alice, &token_addr, &4_000i128, &1_000u64); + client.contribute(&101u64, &bob, &6_000i128); + + let maintainer = Address::generate(&env); + client.release(&101u64, &vec![&env, (maintainer.clone(), 10_000u32)]); + + // 5% fee off the combined 10_000 total, same as a single-sponsor release. + assert_eq!(token_client.balance(&treasury), 500i128); + assert_eq!(token_client.balance(&maintainer), 9_500i128); + assert_eq!(client.get_escrow(&101u64).status, EscrowStatus::Paid); +} From 6a92e18db303dc20acdd2c0a72a15170642f7eb2 Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:04:05 +0100 Subject: [PATCH 10/12] =?UTF-8?q?test:=20cover=20contribute()=20guards=20?= =?UTF-8?q?=E2=80=94=20auth,=20invalid=20amount,=20unknown=20escrow,=20ter?= =?UTF-8?q?minal=20status,=20MAX=5FSPONSORS=20cap=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests exercising contribute()'s validation surface: rejects without sponsor auth, rejects amount <= 0, rejects an unknown issue_id, rejects once the escrow is already Paid, and rejects the 21st distinct contribution once MAX_SPONSORS (20) is reached — the resource-bound guard the design doc calls out as addressing the same unbounded- collection concern raised elsewhere in this codebase (#8/#9). --- contracts/escrow/src/test.rs | 103 +++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 1a0c8a8..ec1ff52 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -553,3 +553,106 @@ fn test_multi_sponsor_release_pays_out_the_combined_total() { assert_eq!(token_client.balance(&maintainer), 9_500i128); assert_eq!(client.get_escrow(&101u64).status, EscrowStatus::Paid); } + +#[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.fund(&102u64, &alice, &token_addr, &5_000i128, &1_000u64); + + // No auth provided for bob's contribution. + env.set_auths(&[]); + let result = client.try_contribute(&102u64, &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); + + client.fund(&103u64, &alice, &token_addr, &5_000i128, &1_000u64); + + let err = client.try_contribute(&103u64, &bob, &0i128); + assert_eq!(err, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_contribute_rejects_unknown_escrow() { + 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::EscrowNotFound))); +} + +#[test] +fn test_contribute_rejects_after_already_paid() { + 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); + let maintainer = Address::generate(&env); + asset_client.mint(&alice, &10_000i128); + asset_client.mint(&bob, &10_000i128); + + client.fund(&104u64, &alice, &token_addr, &5_000i128, &1_000u64); + client.release(&104u64, &vec![&env, (maintainer, 10_000u32)]); + + let err = client.try_contribute(&104u64, &bob, &1_000i128); + assert_eq!(err, Err(Ok(Error::AlreadyPaid))); +} + +#[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, &10_000i128); + + client.fund(&105u64, &alice, &token_addr, &1_000i128, &1_000u64); + + // MAX_SPONSORS is 20; alice's `fund` call 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(&105u64, &extra, &1_000i128); + } + assert_eq!( + client.get_escrow(&105u64).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(&105u64, &one_too_many, &1_000i128); + assert_eq!(err, Err(Ok(Error::TooManySponsors))); +} From a2ff389e84e893bff3ebce6b014717ab4fdd178f Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:05:54 +0100 Subject: [PATCH 11/12] test: cover any-contributor extend_deadline, non-contributor rejection, and get_contribution (#57) Three tests: a second (non-original) contributor can successfully extend the shared deadline, a stranger who never contributed is rejected with Unauthorized even with valid auth for themselves, and get_contribution correctly enumerates each recorded contributor by index and returns EscrowNotFound past the last one. --- contracts/escrow/src/test.rs | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index ec1ff52..7fd4390 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -656,3 +656,79 @@ fn test_contribute_rejects_beyond_max_sponsors() { let err = client.try_contribute(&105u64, &one_too_many, &1_000i128); assert_eq!(err, Err(Ok(Error::TooManySponsors))); } + +#[test] +fn test_extend_deadline_any_contributor_can_extend_not_just_the_original_funder() { + 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); + + env.ledger().set_timestamp(100); + client.fund(&106u64, &alice, &token_addr, &5_000i128, &200u64); + client.contribute(&106u64, &bob, &5_000i128); + + // Bob (the second contributor, not the original funder) extends. + client.extend_deadline(&106u64, &bob, &500u64); + assert_eq!(client.get_escrow(&106u64).deadline, 500u64); + + // The old deadline (200) has passed, but the extended one (500) hasn't: + // refund must still require admin auth. + env.ledger().set_timestamp(300); + env.set_auths(&[]); + let result = client.try_refund(&106u64); + assert!(result.is_err()); +} + +#[test] +fn test_extend_deadline_rejects_non_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); + asset_client.mint(&alice, &10_000i128); + + client.fund(&107u64, &alice, &token_addr, &5_000i128, &1_000u64); + + // A stranger who never contributed to this escrow, even with valid + // auth for themselves, cannot extend it. + let stranger = Address::generate(&env); + let err = client.try_extend_deadline(&107u64, &stranger, &2_000u64); + assert_eq!(err, Err(Ok(Error::Unauthorized))); +} + +#[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.fund(&108u64, &alice, &token_addr, &4_000i128, &1_000u64); + client.contribute(&108u64, &bob, &6_000i128); + + let c0 = client.get_contribution(&108u64, &0u32); + let c1 = client.get_contribution(&108u64, &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(&108u64, &2u32); + assert_eq!(err, Err(Ok(Error::EscrowNotFound))); +} From 0d3a3ab886a5e3bbd7e0cf5b269176c4502b2c2e Mon Sep 17 00:00:00 2001 From: circleboyslimited <277285735+circleboyslimited@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:06:12 +0100 Subject: [PATCH 12/12] docs: update README escrow API reference for contribute()/get_contribution() and extend_deadline's new signature (#57) Brings the README's escrow section back in sync with the implemented API: adds contribute and get_contribution to the function list and descriptions, updates extend_deadline's signature and description for the caller param and any-contributor semantics, and updates the Escrow/ Contribution data model block (dropped sponsor field, added contributor_count and the new Contribution struct). --- README.md | 60 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 55b3f9d..6035547 100644 --- a/README.md +++ b/README.md @@ -92,39 +92,59 @@ Core single-issue bounty escrow. ```rust fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>; fn fund(env, issue_id: u64, sponsor: Address, token: Address, amount: i128, deadline: u64) -> Result<(), Error>; +fn contribute(env, issue_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>; fn release(env, issue_id: u64, recipients: Vec<(Address, u32)>) -> Result<(), Error>; fn refund(env, issue_id: u64) -> Result<(), Error>; -fn extend_deadline(env, issue_id: u64, new_deadline: u64) -> Result<(), Error>; +fn extend_deadline(env, issue_id: u64, caller: Address, new_deadline: u64) -> Result<(), Error>; fn get_escrow(env, issue_id: u64) -> Result; +fn get_contribution(env, issue_id: u64, index: u32) -> Result; fn get_admin(env) -> Result; fn get_treasury(env) -> Result; fn get_fee_bps(env) -> Result; ``` - `fund`: `sponsor.require_auth()`. Transfers `amount` of `token` from the - sponsor into the contract. One escrow per `issue_id` — a second `fund` - call on the same id is rejected (`AlreadyFunded`) rather than silently - topping it up, so an issue's terms can't change after the fact. + sponsor into the contract and *creates* the escrow. One escrow per + `issue_id` — a second `fund` call on the same id is rejected + (`AlreadyFunded`); every sponsor after the first uses `contribute` + instead. +- `contribute`: `sponsor.require_auth()`. Adds an additional sponsor's + funds to an already-`fund`ed escrow — this is how crowdfunding a single + `issue_id` across several sponsors works. Uses the token already + recorded on the escrow (no `token` param, so a top-up can't silently use + a different asset). Each contribution is recorded individually + (`Contribution { sponsor, amount }`, queryable via `get_contribution`) + so `refund` can return each sponsor's own amount to their own address. + Capped at `MAX_SPONSORS` (20) distinct contributions per escrow + (`TooManySponsors` otherwise). Rejects `AlreadyPaid` / `AlreadyRefunded`. + See `docs/escrow-crowdfunding-design.md` for the full design reasoning. - `release`: admin-only (`require_auth` on the stored admin/oracle address). `recipients` basis points must sum to exactly 10000 or the call is rejected (`InvalidSplit`) — this is how team-bounty payouts work, a single recipient at 10000 bps is just the single-payee case. Deducts `fee_bps` off the top to the treasury, splits the rest pro-rata, with the last recipient absorbing integer-division remainder - so no dust is stranded in the contract. Rejects `AlreadyPaid` / - `AlreadyRefunded`. -- `refund`: sponsor gets `amount` back. Callable by the admin at any time - (e.g. issue cancelled), or by *anyone* once `deadline` has passed — - refund is sponsor-protective, so it deliberately doesn't require the - sponsor's own signature. Rejects `AlreadyPaid` / `AlreadyRefunded`. See + so no dust is stranded in the contract. Pays out the full crowdfunded + total (`escrow.amount`, the sum of every contribution) regardless of + how many sponsors contributed. Rejects `AlreadyPaid` / `AlreadyRefunded`. +- `refund`: every contributor gets back exactly what *they* put in, to + their own address — not an even split and not the full amount to a + single sponsor. Callable by the admin at any time (e.g. issue + cancelled), or by *anyone* once `deadline` has passed — refund is + sponsor-protective, so it deliberately doesn't require any contributor's + own signature. Rejects `AlreadyPaid` / `AlreadyRefunded`. See `docs/refund-permissionless-analysis.md` for the economics/griefing analysis of the permissionless path. -- `extend_deadline`: `sponsor.require_auth()`. Lets the sponsor push - their own `deadline` later if they want more time before `refund`'s - permissionless path opens — `new_deadline` must be strictly later than - both the stored deadline and the current ledger time, so it can only - delay that window, never shorten it, and only the sponsor can call it. - Rejects `AlreadyPaid` / `AlreadyRefunded`. +- `extend_deadline`: `caller.require_auth()`, and `caller` must be *any* + current contributor to the escrow (not necessarily the original `fund` + caller) — rejected with `Unauthorized` otherwise. Lets a contributor + push the shared `deadline` later if the group wants more time before + `refund`'s permissionless path opens — `new_deadline` must be strictly + later than both the stored deadline and the current ledger time, so it + can only delay that window, never shorten it. Rejects `AlreadyPaid` / + `AlreadyRefunded`. See `docs/escrow-crowdfunding-design.md` for why any + single contributor (rather than unanimous or weighted consent) can + extend. ### 2. `contracts/milestones` — `mergefi-milestones` @@ -186,12 +206,16 @@ fn get_deposit(env, pool_id: u64, index: u32) -> Result; // escrow pub enum EscrowStatus { Funded, Paid, Refunded } pub struct Escrow { - pub sponsor: Address, pub token: Address, - pub amount: i128, + pub amount: i128, // sum of every contribution accepted so far pub status: EscrowStatus, pub created_at: u64, pub deadline: u64, + pub contributor_count: u32, // enumerate via get_contribution(0..contributor_count) +} +pub struct Contribution { + pub sponsor: Address, + pub amount: i128, } // milestones