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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ systematically rewarding the final recipient.
Core single-issue bounty escrow.

```rust
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32, max_sponsors: Option<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>;
Expand All @@ -156,6 +156,7 @@ fn get_contribution(env, issue_id: u64, index: u32) -> Result<Contribution, Erro
fn get_admin(env) -> Result<Address, Error>;
fn get_treasury(env) -> Result<Address, Error>;
fn get_fee_bps(env) -> Result<u32, Error>;
fn get_max_sponsors(env) -> Result<u32, Error>;
```

- `fund`: `sponsor.require_auth()`. Transfers `amount` of `token` from the
Expand All @@ -170,7 +171,8 @@ fn get_fee_bps(env) -> Result<u32, Error>;
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
Capped at `max_sponsors` (an optional `initialize` parameter, defaulting
to `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
Expand Down Expand Up @@ -218,7 +220,7 @@ fn get_fee_bps(env) -> Result<u32, Error>;
Lump-sum budget shared across the issues in a release.

```rust
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32) -> Result<(), Error>;
fn initialize(env, admin: Address, treasury: Address, fee_bps: u32, max_sponsors: Option<u32>) -> Result<(), Error>;
fn create_milestone(env, milestone_id: u64, sponsor: Address, token: Address, total_budget: i128) -> Result<(), Error>;
fn contribute(env, milestone_id: u64, sponsor: Address, amount: i128) -> Result<(), Error>;
fn allocate(env, milestone_id: u64, issue_id: u64, amount: i128) -> Result<(), Error>;
Expand All @@ -227,6 +229,7 @@ fn cancel_milestone(env, milestone_id: u64) -> Result<(), Error>;
fn get_milestone(env, milestone_id: u64) -> Result<Milestone, Error>;
fn get_issue_status(env, milestone_id: u64, issue_id: u64) -> Result<IssueStatus, Error>;
fn get_contribution(env, milestone_id: u64, index: u32) -> Result<Contribution, Error>;
fn get_max_sponsors(env) -> Result<u32, Error>;
```

- `create_milestone`: the original sponsor deposits `total_budget` once;
Expand All @@ -243,7 +246,8 @@ fn get_contribution(env, milestone_id: u64, index: u32) -> Result<Contribution,
contribution is recorded individually (`Contribution { sponsor, amount
}`, 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` (20)
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).
Rejects `MilestoneClosed`. See
`docs/milestones-crowdfunding-design.md` for the full design reasoning.
Expand Down Expand Up @@ -275,6 +279,9 @@ fn deposit(env, pool_id: u64, sponsor: Address, token: Address, amount: i128) ->
fn withdraw(env, pool_id: u64, recipient: Address, amount: i128) -> Result<(), Error>;
fn get_pool(env, pool_id: u64) -> Result<MaintenancePool, Error>;
fn get_deposit(env, pool_id: u64, index: u32) -> Result<Deposit, Error>;
fn get_admin(env) -> Result<Address, Error>;
fn get_treasury(env) -> Result<Address, Error>;
fn get_fee_bps(env) -> Result<u32, Error>;
```

- `pool_id` is an off-chain-assigned identifier for a repo or org (e.g. a
Expand Down
27 changes: 25 additions & 2 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ 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
/// Default maximum number of distinct contributions (sponsors) a single
/// escrow can accumulate, used when `initialize` isn't given an explicit
/// `max_sponsors`. 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;
Expand All @@ -47,11 +48,16 @@ impl EscrowContract {
/// — closing that race requires an atomic deploy+init (a Soroban
/// constructor) rather than an in-contract check; see
/// `docs/access-control-audit.md`.
///
/// `max_sponsors` caps how many distinct contributions a single escrow
/// may accumulate (see `contribute`); pass `None` to use the default
/// `MAX_SPONSORS` (20).
pub fn initialize(
env: Env,
admin: Address,
treasury: Address,
fee_bps: u32,
max_sponsors: Option<u32>,
) -> Result<(), Error> {
admin.require_auth();

Expand All @@ -65,6 +71,10 @@ impl EscrowContract {
env.storage().instance().set(&DataKey::Admin, &admin);
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),
);
Ok(())
}

Expand Down Expand Up @@ -172,6 +182,12 @@ impl EscrowContract {
EscrowStatus::Funded => {}
}

let max_sponsors: u32 = env
.storage()
.instance()
.get(&DataKey::MaxSponsors)
.unwrap_or(MAX_SPONSORS);
if escrow.contributor_count >= max_sponsors {
let mut existing_index = None;
for i in 0..escrow.contributor_count {
let contribution_key = DataKey::Contribution(issue_id, i);
Expand Down Expand Up @@ -520,6 +536,13 @@ impl EscrowContract {
.get(&DataKey::FeeBps)
.ok_or(Error::NotInitialized)
}

pub fn get_max_sponsors(env: Env) -> Result<u32, Error> {
env.storage()
.instance()
.get(&DataKey::MaxSponsors)
.ok_or(Error::NotInitialized)
}
}

pub(crate) fn require_admin(env: &Env) -> Result<Address, Error> {
Expand Down
53 changes: 46 additions & 7 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ fn setup(env: &Env) -> (Address, Address, Address, EscrowContractClient<'_>) {
let treasury = Address::generate(env);
let contract_id = env.register(EscrowContract, ());
let client = EscrowContractClient::new(env, &contract_id);
client.initialize(&admin, &treasury, &500u32); // 5% fee
client.initialize(&admin, &treasury, &500u32, &None); // 5% fee
(contract_id, admin, treasury, client)
}

Expand All @@ -33,7 +33,7 @@ fn test_initialize_rejects_double_init() {
let env = Env::default();
env.mock_all_auths();
let (_, admin, treasury, client) = setup(&env);
let err = client.try_initialize(&admin, &treasury, &500u32);
let err = client.try_initialize(&admin, &treasury, &500u32, &None);
assert_eq!(err, Err(Ok(Error::AlreadyInitialized)));
}

Expand All @@ -46,7 +46,7 @@ fn test_initialize_rejects_fee_bps_above_10000() {
let contract_id = env.register(EscrowContract, ());
let client = EscrowContractClient::new(&env, &contract_id);

let err = client.try_initialize(&admin, &treasury, &10_001u32);
let err = client.try_initialize(&admin, &treasury, &10_001u32, &None);
assert_eq!(err, Err(Ok(Error::InvalidFee)));
}

Expand All @@ -59,7 +59,7 @@ fn test_initialize_accepts_fee_bps_at_boundary_10000() {
let contract_id = env.register(EscrowContract, ());
let client = EscrowContractClient::new(&env, &contract_id);

client.initialize(&admin, &treasury, &10_000u32);
client.initialize(&admin, &treasury, &10_000u32, &None);
assert_eq!(client.get_fee_bps(), 10_000u32);
}

Expand Down Expand Up @@ -273,7 +273,7 @@ fn test_adversarial_ordering_resistance() {
let client = crate::EscrowContractClient::new(&env, &contract_id);

// Initialize with 0% fee to simplify fraction/dust calculations
client.initialize(&admin, &treasury, &0u32);
client.initialize(&admin, &treasury, &0u32, &None);

// 2. Create recipient addresses
let dev1 = Address::generate(&env);
Expand Down Expand Up @@ -337,7 +337,7 @@ fn test_large_split_distributes_dust_by_largest_remainder() {
let contract_id = env.register(crate::EscrowContract, ());
let client = crate::EscrowContractClient::new(&env, &contract_id);
// 0% fee so the whole total is distributable.
client.initialize(&admin, &treasury, &0u32);
client.initialize(&admin, &treasury, &0u32, &None);

// 60 recipients: 59 with alternating 160/170 bps, the last one receiving
// the leftover of 10000. All 170-bps recipients share an identical
Expand Down Expand Up @@ -425,7 +425,7 @@ fn test_initialize_requires_admin_auth() {
let contract_id = env.register(EscrowContract, ());
let client = EscrowContractClient::new(&env, &contract_id);

let result = client.try_initialize(&admin, &treasury, &500u32);
let result = client.try_initialize(&admin, &treasury, &500u32, &None);
assert!(result.is_err());
}

Expand Down Expand Up @@ -775,6 +775,45 @@ fn test_contribute_rejects_beyond_max_sponsors() {
assert_eq!(err, Err(Ok(Error::TooManySponsors)));
}

#[test]
fn test_get_max_sponsors_defaults_to_the_constant_when_not_specified() {
let env = Env::default();
env.mock_all_auths();
let (_, _admin, _treasury, client) = setup(&env);

assert_eq!(client.get_max_sponsors(), crate::MAX_SPONSORS);
}

#[test]
fn test_initialize_accepts_a_custom_max_sponsors() {
let env = Env::default();
env.mock_all_auths();
let admin = Address::generate(&env);
let treasury = Address::generate(&env);
let contract_id = env.register(EscrowContract, ());
let client = EscrowContractClient::new(&env, &contract_id);
client.initialize(&admin, &treasury, &500u32, &Some(2u32));

assert_eq!(client.get_max_sponsors(), 2u32);

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(&106u64, &alice, &token_addr, &1_000i128, &1_000u64);

let bob = Address::generate(&env);
asset_client.mint(&bob, &1_000i128);
client.contribute(&106u64, &bob, &1_000i128);

// With max_sponsors == 2, alice's `fund` (slot 0) plus bob's
// `contribute` (slot 1) already fill the custom cap.
let carol = Address::generate(&env);
asset_client.mint(&carol, &1_000i128);
let err = client.try_contribute(&106u64, &carol, &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();
Expand Down
1 change: 1 addition & 0 deletions contracts/escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub enum DataKey {
Admin,
Treasury,
FeeBps,
MaxSponsors,
Escrow(u64),
Contribution(u64, u32), // (issue_id, contribution_index)
}
Expand Down
21 changes: 21 additions & 0 deletions contracts/maintenance-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@ impl MaintenancePoolContract {
.get(&DataKey::Deposit(pool_id, index))
.ok_or(Error::PoolNotFound)
}

pub fn get_admin(env: Env) -> Result<Address, Error> {
env.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)
}

pub fn get_treasury(env: Env) -> Result<Address, Error> {
env.storage()
.instance()
.get(&DataKey::Treasury)
.ok_or(Error::NotInitialized)
}

pub fn get_fee_bps(env: Env) -> Result<u32, Error> {
env.storage()
.instance()
.get(&DataKey::FeeBps)
.ok_or(Error::NotInitialized)
}
}

fn require_admin(env: &Env) -> Result<Address, Error> {
Expand Down
22 changes: 22 additions & 0 deletions contracts/maintenance-pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ fn setup(env: &Env) -> (Address, Address, MaintenancePoolContractClient<'_>) {
(admin, treasury, client)
}

#[test]
fn test_get_admin_treasury_fee_bps() {
let env = Env::default();
env.mock_all_auths();
let (admin, treasury, client) = setup(&env);

assert_eq!(client.get_admin(), admin);
assert_eq!(client.get_treasury(), treasury);
assert_eq!(client.get_fee_bps(), 1_000u32);
}

#[test]
fn test_get_admin_treasury_fee_bps_before_initialize() {
let env = Env::default();
let contract_id = env.register(MaintenancePoolContract, ());
let client = MaintenancePoolContractClient::new(&env, &contract_id);

assert_eq!(client.try_get_admin(), Err(Ok(Error::NotInitialized)));
assert_eq!(client.try_get_treasury(), Err(Ok(Error::NotInitialized)));
assert_eq!(client.try_get_fee_bps(), Err(Ok(Error::NotInitialized)));
}

#[test]
fn test_get_pool_and_withdraw_reject_nonexistent_pool() {
let env = Env::default();
Expand Down
27 changes: 25 additions & 2 deletions contracts/milestones/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ use types::{Contribution, DataKey, IssueStatus, Milestone};

pub const BPS_DENOMINATOR: i128 = 10_000;

/// Maximum number of distinct contributions (sponsors) a single milestone
/// can accumulate. Bounds the per-contributor loop in `cancel_milestone`
/// Default maximum number of distinct contributions (sponsors) a single
/// milestone can accumulate, used when `initialize` isn't given an explicit
/// `max_sponsors`. Bounds the per-contributor loop in `cancel_milestone`
/// (and any future timeout-triggered wind-down that reuses
/// `refund_remaining_budget`) to a small, predictable constant regardless
/// of how popular a release gets. See `docs/milestones-crowdfunding-design.md`.
Expand All @@ -39,11 +40,16 @@ impl MilestonesContract {
/// name a third-party address as admin without that address's consent
/// — see `docs/access-control-audit.md` for what this does and does
/// not protect against (it does not stop initializer front-running).
///
/// `max_sponsors` caps how many distinct contributions a single
/// milestone may accumulate (see `contribute`); pass `None` to use the
/// default `MAX_SPONSORS` (20).
pub fn initialize(
env: Env,
admin: Address,
treasury: Address,
fee_bps: u32,
max_sponsors: Option<u32>,
) -> Result<(), Error> {
admin.require_auth();

Expand All @@ -56,6 +62,10 @@ impl MilestonesContract {
env.storage().instance().set(&DataKey::Admin, &admin);
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),
);
Ok(())
}

Expand Down Expand Up @@ -144,6 +154,12 @@ impl MilestonesContract {
if milestone.closed {
return Err(Error::MilestoneClosed);
}
let max_sponsors: u32 = env
.storage()
.instance()
.get(&DataKey::MaxSponsors)
.unwrap_or(MAX_SPONSORS);
if milestone.contributor_count >= max_sponsors {
let mut existing_index = None;
for i in 0..milestone.contributor_count {
let contribution_key = DataKey::Contribution(milestone_id, i);
Expand Down Expand Up @@ -421,6 +437,13 @@ impl MilestonesContract {
.get(&DataKey::Contribution(milestone_id, index))
.ok_or(Error::MilestoneNotFound)
}

pub fn get_max_sponsors(env: Env) -> Result<u32, Error> {
env.storage()
.instance()
.get(&DataKey::MaxSponsors)
.ok_or(Error::NotInitialized)
}
}

/// Pays each contributor their share of `milestone.remaining_budget` (the
Expand Down
Loading