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
60 changes: 42 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Escrow, Error>;
fn get_contribution(env, issue_id: u64, index: u32) -> Result<Contribution, Error>;
fn get_admin(env) -> Result<Address, Error>;
fn get_treasury(env) -> Result<Address, Error>;
fn get_fee_bps(env) -> Result<u32, Error>;
```

- `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`

Expand Down Expand Up @@ -186,12 +206,16 @@ fn get_deposit(env, pool_id: u64, index: u32) -> Result<Deposit, Error>;
// 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
Expand Down
1 change: 1 addition & 0 deletions contracts/escrow/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ pub enum Error {
InsufficientBalance = 11,
InvalidFee = 12,
InvalidDeadline = 13,
TooManySponsors = 14,
}
164 changes: 141 additions & 23 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -83,20 +94,80 @@ 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);

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`,
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -179,30 +262,53 @@ 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()
.persistent()
.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);
}
Expand All @@ -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<Contribution, Error> {
env.storage()
.persistent()
.get(&DataKey::Contribution(issue_id, index))
.ok_or(Error::EscrowNotFound)
}

pub fn get_admin(env: Env) -> Result<Address, Error> {
env.storage()
.instance()
Expand Down
Loading
Loading