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 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..c9f95cb 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; @@ -58,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, @@ -83,13 +94,19 @@ 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, amount }); + extend_ttl(&env, &contribution_key); + let escrow = Escrow { - sponsor, token, amount, status: EscrowStatus::Funded, created_at: env.ledger().timestamp(), deadline, + contributor_count: 1, }; env.storage().persistent().set(&key, &escrow); extend_ttl(&env, &key); @@ -97,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`, @@ -142,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 @@ -166,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); @@ -179,15 +262,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() @@ -195,14 +289,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); } @@ -222,6 +328,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() diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 5e41e70..7fd4390 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,259 @@ 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))); } + +// --------------------------------------------------------------------------- +// 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); +} + +#[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))); +} + +#[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))); +} diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index e223b0c..11923e5 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -11,12 +11,25 @@ pub enum EscrowStatus { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Escrow { - pub sponsor: Address, pub token: Address, pub amount: i128, 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 +39,5 @@ pub enum DataKey { Treasury, FeeBps, Escrow(u64), + Contribution(u64, u32), // (issue_id, contribution_index) } 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.