From 867566635a0096f0d756b34ed767ca92866f1846 Mon Sep 17 00:00:00 2001 From: Eric Date: Mon, 19 Oct 2026 15:51:26 +0100 Subject: [PATCH] Bound sponsor and dust loops Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 10 +++++--- contracts/common/src/lib.rs | 4 ++++ contracts/common/src/split.rs | 3 ++- contracts/escrow/src/error.rs | 2 ++ contracts/escrow/src/lib.rs | 10 +++++--- contracts/escrow/src/test.rs | 32 +++++++++++++++++++++++++ contracts/milestones/src/error.rs | 2 ++ contracts/milestones/src/lib.rs | 14 +++++++---- contracts/milestones/src/test.rs | 33 +++++++++++++++++++++++++- docs/escrow-crowdfunding-design.md | 13 +++++----- docs/milestones-crowdfunding-design.md | 10 ++++---- scripts/error-codes.mjs | 6 +++-- scripts/invoke.mjs | 22 +++++++++++++++++ scripts/invoke.test.mjs | 24 +++++++++++++++++++ 14 files changed, 159 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4761400b..422a61b2 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ fn set_treasury(env, new_treasury: Address) -> Result<(), Error>; (`Contribution { sponsor, amount }`, queryable via `get_contribution`) so `refund` can return each sponsor's own amount to their own address. Capped at `max_sponsors` (an optional `initialize` parameter, defaulting - to `MAX_SPONSORS`, 20) distinct contributions per escrow + to `MAX_SPONSORS`, 20, and limited to 50) distinct contributions per escrow (`TooManySponsors` otherwise). Rejects `AlreadyPaid` / `AlreadyRefunded`. See `docs/escrow-crowdfunding-design.md` for the full design reasoning. - `release`: oracle-only (`require_auth` on the stored oracle address). @@ -278,8 +278,9 @@ fn set_treasury(env, new_treasury: Address) -> Result<(), Error>; }`, queryable via `get_contribution`, with the original funder always at index 0) so a cancellation refund can return each sponsor's proportional share to their own address. Capped at `max_sponsors` (an - optional `initialize` parameter, defaulting to `MAX_SPONSORS`, 20) - distinct contributions per milestone (`TooManySponsors` otherwise). + optional `initialize` parameter, defaulting to `MAX_SPONSORS`, 20) and + limited to 50 distinct contributions per milestone (`TooManySponsors` + otherwise). Rejects `MilestoneClosed`. See `docs/milestones-crowdfunding-design.md` for the full design reasoning. - `allocate`: admin-only. Reserves a slice of `remaining_budget` for a @@ -708,6 +709,9 @@ Stellar-side problem), so `scripts/deploy.mjs` and `scripts/invoke.mjs` (thin wrappers around `@stellar/stellar-sdk`) were used instead to perform the same upload → create-contract → initialize flow the CLI would otherwise do. +For payout methods such as `release` and `release_issue`, `invoke.mjs` +accepts recipient splits as `recipients:
=,
=` +(for example, `recipients:G...=7000,G...=3000`). | Contract | Contract ID | Stellar Expert | |---|---|---| diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index c6a1a7a6..968a78cc 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -15,6 +15,10 @@ pub use split::{compute_split, sort_remainders_desc, Payouts, SplitError}; #[cfg(test)] mod test_fuzz; +/// Maximum accepted per-contract sponsor cap. This keeps contributor loops +/// within a predictable resource budget even when deployments customize it. +pub const MAX_SPONSORS_LIMIT: u32 = 50; + /// Trait to identify the Admin key for a contract's DataKey enum. pub trait AdminKey { /// Returns the storage key for the admin address. diff --git a/contracts/common/src/split.rs b/contracts/common/src/split.rs index 100f8439..9220f728 100644 --- a/contracts/common/src/split.rs +++ b/contracts/common/src/split.rs @@ -110,7 +110,8 @@ pub fn compute_split( .ok_or(SplitError::Overflow)?; if dust > 0 { sort_remainders_desc(&mut order); - for k in 0..dust as u32 { + let dust = u32::try_from(dust).map_err(|_| SplitError::Overflow)?; + for k in 0..dust { let (index, _, _) = order.get(k).unwrap(); let (recipient, share) = shares.get(index).unwrap(); shares.set( diff --git a/contracts/escrow/src/error.rs b/contracts/escrow/src/error.rs index 4c130c24..16b73c69 100644 --- a/contracts/escrow/src/error.rs +++ b/contracts/escrow/src/error.rs @@ -35,4 +35,6 @@ pub enum Error { ArithmeticOverflow = 21, /// The stored contract version does not match the requested migration source. InvalidVersion = 22, + /// `initialize` supplied a sponsor cap above `MAX_SPONSORS_LIMIT`. + InvalidMaxSponsors = 23, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 4096f556..7b66ff4e 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -15,7 +15,7 @@ mod types; mod test; use error::Error; -use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS}; +use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT}; use soroban_sdk::{contract, contractevent, contractimpl, token, Address, BytesN, Env, Vec}; use types::{Contribution, DataKey, Escrow, EscrowStatus}; @@ -206,7 +206,8 @@ mod contract { /// /// `max_sponsors` caps how many distinct contributions a single escrow /// may accumulate (see `contribute`); pass `None` to use the default - /// `MAX_SPONSORS` (20). + /// `MAX_SPONSORS` (20). Custom values may not exceed + /// `MAX_SPONSORS_LIMIT` (50), keeping contributor loops bounded. pub fn initialize( env: Env, admin: Address, @@ -228,12 +229,15 @@ mod contract { if treasury == env.current_contract_address() { return Err(Error::InvalidTreasury); } + let max_sponsors = max_sponsors.unwrap_or(MAX_SPONSORS); + if max_sponsors > MAX_SPONSORS_LIMIT { + return Err(Error::InvalidMaxSponsors); + } env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Oracle, &oracle); env.storage().instance().set(&DataKey::Treasury, &treasury); env.storage().instance().set(&DataKey::FeeBps, &fee_bps); - let max_sponsors = max_sponsors.unwrap_or(MAX_SPONSORS); env.storage() .instance() .set(&DataKey::MaxSponsors, &max_sponsors); diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 47a07d1e..22b9c1d6 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -1,6 +1,7 @@ #![cfg(test)] use super::*; +use mergefi_common::MAX_SPONSORS_LIMIT; use soroban_sdk::{ testutils::{storage::Persistent as _, Address as _, Ledger}, token, vec, Address, Env, Event, @@ -1254,6 +1255,37 @@ fn test_initialize_accepts_a_custom_max_sponsors() { assert_eq!(err, Err(Ok(Error::TooManySponsors))); } +#[test] +fn test_initialize_caps_max_sponsors_at_shared_limit() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(&env, &contract_id); + + let err = client.try_initialize( + &admin, + &oracle, + &treasury, + &500u32, + &Some(MAX_SPONSORS_LIMIT + 1), + &None, + ); + assert_eq!(err, Err(Ok(Error::InvalidMaxSponsors))); + + client.initialize( + &admin, + &oracle, + &treasury, + &500u32, + &Some(MAX_SPONSORS_LIMIT), + &None, + ); + assert_eq!(client.get_max_sponsors(), MAX_SPONSORS_LIMIT); +} + #[test] fn test_extend_deadline_rejects_later_contributor_only_original_sponsor_can_extend() { let env = Env::default(); diff --git a/contracts/milestones/src/error.rs b/contracts/milestones/src/error.rs index 753ea058..d1f34d8d 100644 --- a/contracts/milestones/src/error.rs +++ b/contracts/milestones/src/error.rs @@ -45,4 +45,6 @@ pub enum Error { DeadlineInPast = 22, /// The stored contract version does not match the requested migration source. InvalidVersion = 23, + /// `initialize` supplied a sponsor cap above `MAX_SPONSORS_LIMIT`. + InvalidMaxSponsors = 24, } diff --git a/contracts/milestones/src/lib.rs b/contracts/milestones/src/lib.rs index 35b8eaab..49682efb 100644 --- a/contracts/milestones/src/lib.rs +++ b/contracts/milestones/src/lib.rs @@ -22,7 +22,7 @@ use error::Error; use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Map, Vec}; use types::{Contribution, DataKey, IssueStatus, Milestone}; -use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS}; +use mergefi_common::{BPS_DENOMINATOR, MAX_SPONSORS, MAX_SPONSORS_LIMIT}; /// Minimum grace period (in seconds) after the deadline before anyone can /// permissionlessly trigger a cancel_milestone. Mirrors escrow's @@ -53,7 +53,8 @@ mod contract { /// /// `max_sponsors` caps how many distinct contributions a single /// milestone may accumulate (see `contribute`); pass `None` to use the - /// default `MAX_SPONSORS` (20). + /// default `MAX_SPONSORS` (20). Custom values may not exceed + /// `MAX_SPONSORS_LIMIT` (50), keeping contributor loops bounded. pub fn initialize( env: Env, admin: Address, @@ -75,13 +76,17 @@ mod contract { if treasury == env.current_contract_address() { return Err(Error::InvalidTreasury); } + let max_sponsors = max_sponsors.unwrap_or(MAX_SPONSORS); + if max_sponsors > MAX_SPONSORS_LIMIT { + return Err(Error::InvalidMaxSponsors); + } env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::Oracle, &oracle); env.storage().instance().set(&DataKey::Treasury, &treasury); env.storage().instance().set(&DataKey::FeeBps, &fee_bps); env.storage() .instance() - .set(&DataKey::MaxSponsors, &max_sponsors.unwrap_or(MAX_SPONSORS)); + .set(&DataKey::MaxSponsors, &max_sponsors); env.storage() .instance() .set(&DataKey::Version, &CONTRACT_VERSION); @@ -957,7 +962,8 @@ fn refund_remaining_budget( .ok_or(Error::ArithmeticOverflow)?; if dust > 0 { mergefi_common::sort_remainders_desc(&mut order); - for k in 0..dust as u32 { + let dust = u32::try_from(dust).map_err(|_| Error::ArithmeticOverflow)?; + for k in 0..dust { let (index, _, _) = order.get(k).unwrap(); let (recipient, share) = shares.get(index).unwrap(); shares.set( diff --git a/contracts/milestones/src/test.rs b/contracts/milestones/src/test.rs index 21cd988c..c13e68a9 100644 --- a/contracts/milestones/src/test.rs +++ b/contracts/milestones/src/test.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use mergefi_common::MAX_SPONSORS; +use mergefi_common::{MAX_SPONSORS, MAX_SPONSORS_LIMIT}; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, token, vec, Address, Env, @@ -754,6 +754,37 @@ fn test_initialize_accepts_a_custom_max_sponsors() { assert_eq!(err, Err(Ok(Error::TooManySponsors))); } +#[test] +fn test_initialize_caps_max_sponsors_at_shared_limit() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let treasury = Address::generate(&env); + let contract_id = env.register(MilestonesContract, ()); + let client = MilestonesContractClient::new(&env, &contract_id); + + let err = client.try_initialize( + &admin, + &oracle, + &treasury, + &500u32, + &Some(MAX_SPONSORS_LIMIT + 1), + &None, + ); + assert_eq!(err, Err(Ok(Error::InvalidMaxSponsors))); + + client.initialize( + &admin, + &oracle, + &treasury, + &500u32, + &Some(MAX_SPONSORS_LIMIT), + &None, + ); + assert_eq!(client.get_max_sponsors(), MAX_SPONSORS_LIMIT); +} + #[test] fn test_get_contribution_enumerates_each_contributor() { let env = Env::default(); diff --git a/docs/escrow-crowdfunding-design.md b/docs/escrow-crowdfunding-design.md index 8c9fcdcd..61f50a46 100644 --- a/docs/escrow-crowdfunding-design.md +++ b/docs/escrow-crowdfunding-design.md @@ -56,16 +56,16 @@ Two reasons: 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` (an optional `initialize` - parameter, defaulting to the `MAX_SPONSORS` constant, 20, when omitted) + parameter, defaulting to the `MAX_SPONSORS` constant, 20, when omitted + and capped at 50) caps `contributor_count`, so `refund`'s and `extend_deadline`'s per-contributor loops are bounded by a small, per-deployment-tunable 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; - a deployment expecting unusually high participation can raise the cap - at `initialize` time instead of needing a contract redeploy (#96). + elsewhere in this codebase. The default of 20 is generous enough for + realistic crowdfunding on a single GitHub issue; deployments can raise + it to at most 50 at `initialize` time, keeping the worst-case loop (and + its Stellar resource cost) bounded without requiring a redeploy (#96, #448). Because each contribution is recorded as an *exact* amount rather than a percentage, `refund` needs no proportional-split math at all: it iterates @@ -107,4 +107,3 @@ arbitrarily far out in one call. It can be extended again later, still only up to 365 days ahead of that later call. The admin's independent early-refund path (`refund` before `deadline + GRACE_PERIOD`, admin-only) remains available as an escape hatch. - diff --git a/docs/milestones-crowdfunding-design.md b/docs/milestones-crowdfunding-design.md index 1d9be8b3..3b131d92 100644 --- a/docs/milestones-crowdfunding-design.md +++ b/docs/milestones-crowdfunding-design.md @@ -102,14 +102,14 @@ inline on `Milestone`. Two reasons, unchanged from escrow: history, even for operations (`allocate`, `release_issue`) that never look at it. 2. **No unbounded growth.** `max_sponsors` (an optional `initialize` - parameter, defaulting to the `MAX_SPONSORS` constant, 20, when omitted) + parameter, defaulting to the `MAX_SPONSORS` constant, 20, when omitted + and capped at 50) caps `contributor_count`, so `cancel_milestone`'s (and any future timeout wind-down's) per-contributor loop — and, critically, the refund's dust distribution — is bounded by a small, per-deployment- - tunable constant regardless of how popular a release gets. A $50,000 - release milestone expecting far more participation than a single-issue - bounty can raise the cap at `initialize` time instead of needing a - contract redeploy (#96). + tunable constant regardless of how popular a release gets. A release + milestone with unusually high participation can raise the cap to at most + 50 at `initialize` time instead of needing a contract redeploy (#96, #449). ## Authorization diff --git a/scripts/error-codes.mjs b/scripts/error-codes.mjs index 93d47ce6..920ab043 100644 --- a/scripts/error-codes.mjs +++ b/scripts/error-codes.mjs @@ -24,7 +24,8 @@ export const errorMappings = { 16: "InvalidTarget", 17: "InvalidTreasury", 19: "ContractPaused", - 20: "SelfPayout" + 20: "SelfPayout", + 23: "InvalidMaxSponsors" }, // Milestones contract errors @@ -48,7 +49,8 @@ export const errorMappings = { 17: "IssueNotAllocatedForDeallocate", 18: "ContractPaused", 19: "ContributionNotFound", - 20: "SelfPayout" + 20: "SelfPayout", + 24: "InvalidMaxSponsors" }, // Maintenance-pool contract errors diff --git a/scripts/invoke.mjs b/scripts/invoke.mjs index daba36df..042b2836 100644 --- a/scripts/invoke.mjs +++ b/scripts/invoke.mjs @@ -7,6 +7,7 @@ import { Address, nativeToScVal, rpc, + xdr, } from "@stellar/stellar-sdk"; import { submitAndWait } from "./lib/submit.mjs"; import { formatError } from "./error-codes.mjs"; @@ -35,6 +36,27 @@ export function parseArg(raw) { const [type, value] = raw.split(":"); if (type === "address") return nativeToScVal(new Address(value), { type: "address" }); + if (type === "recipients") { + if (!value) { + throw new Error("Invalid recipients argument: expected address=u32,..."); + } + + const recipients = value.split(",").map((entry) => { + const [address, bps, ...extra] = entry.split("="); + if (!address || !bps || extra.length > 0 || !/^\d+$/.test(bps)) { + throw new Error(`Invalid recipients entry: ${entry}`); + } + const share = Number(bps); + if (!Number.isSafeInteger(share) || share > 0xffffffff) { + throw new Error(`Recipient basis points must fit u32: ${bps}`); + } + return xdr.ScVal.scvVec([ + nativeToScVal(new Address(address), { type: "address" }), + nativeToScVal(share, { type: "u32" }), + ]); + }); + return xdr.ScVal.scvVec(recipients); + } if (type === "u32") return nativeToScVal(parseInt(value, 10), { type: "u32" }); if (type === "u64") return nativeToScVal(BigInt(value), { type: "u64" }); if (type === "i128" || type === "some-i128") { diff --git a/scripts/invoke.test.mjs b/scripts/invoke.test.mjs index 2e34f2c8..3ccb6c14 100644 --- a/scripts/invoke.test.mjs +++ b/scripts/invoke.test.mjs @@ -2,6 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +import { Address, nativeToScVal } from "@stellar/stellar-sdk"; import { parseArg } from "./invoke.mjs"; const execFileAsync = promisify(execFile); @@ -39,6 +40,29 @@ describe("scripts/invoke.mjs parseArg unit tests", () => { assert.ok(scVal); }); + test("parses recipients as a vector of address/u32 tuple vectors", () => { + const address1 = "GBZXN7PIRZGNMHGA72BLBHNTFAJUR3NGMHHCC5YDHJL3PPCHQSTFU7H6"; + const address2 = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const recipients = parseArg(`recipients:${address1}=7000,${address2}=3000`).vec(); + + assert.equal(recipients.length, 2); + assert.equal(recipients[0].vec().length, 2); + assert.ok( + recipients[0] + .vec()[0] + .toXDR() + .equals(nativeToScVal(new Address(address1), { type: "address" }).toXDR()) + ); + assert.equal(recipients[0].vec()[1].u32(), 7000); + assert.equal(recipients[1].vec()[1].u32(), 3000); + }); + + test("rejects malformed recipient vectors", () => { + assert.throws(() => parseArg("recipients:"), /expected address=u32/); + assert.throws(() => parseArg("recipients:G...=NaN"), /Invalid recipients entry/); + assert.throws(() => parseArg("recipients:G...=4294967296"), /fit u32/); + }); + test("parses u32 correctly", () => { const scVal = parseArg("u32:250"); assert.ok(scVal);