Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<address>=<bps>,<address>=<bps>`
(for example, `recipients:G...=7000,G...=3000`).

| Contract | Contract ID | Stellar Expert |
|---|---|---|
Expand Down
4 changes: 4 additions & 0 deletions contracts/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion contracts/common/src/split.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions contracts/escrow/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
10 changes: 7 additions & 3 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions contracts/milestones/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
14 changes: 10 additions & 4 deletions contracts/milestones/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
33 changes: 32 additions & 1 deletion contracts/milestones/src/test.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 6 additions & 7 deletions docs/escrow-crowdfunding-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

10 changes: 5 additions & 5 deletions docs/milestones-crowdfunding-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions scripts/error-codes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export const errorMappings = {
16: "InvalidTarget",
17: "InvalidTreasury",
19: "ContractPaused",
20: "SelfPayout"
20: "SelfPayout",
23: "InvalidMaxSponsors"
},

// Milestones contract errors
Expand All @@ -48,7 +49,8 @@ export const errorMappings = {
17: "IssueNotAllocatedForDeallocate",
18: "ContractPaused",
19: "ContributionNotFound",
20: "SelfPayout"
20: "SelfPayout",
24: "InvalidMaxSponsors"
},

// Maintenance-pool contract errors
Expand Down
22 changes: 22 additions & 0 deletions scripts/invoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down
24 changes: 24 additions & 0 deletions scripts/invoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading