diff --git a/README.md b/README.md index 1b76c1f..46232de 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ 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 fund(env, issue_id: u64, sponsor: Address, token: Address, amount: i128, deadline: u64, target: Option) -> 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>; @@ -159,10 +159,12 @@ fn get_fee_bps(env) -> Result; ``` - `fund`: `sponsor.require_auth()`. Transfers `amount` of `token` from the - 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. + sponsor into the contract and *creates* the escrow. `target` is an + optional funding goal stored on the escrow and read-only thereafter — + informational only, so it does not block `contribute` past it and does not + change `release`/`refund` behavior. 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 diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 7d96937..a9dba57 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -71,10 +71,13 @@ impl EscrowContract { /// 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 + /// may reclaim their funds. `target` is an optional funding goal that is + /// stored verbatim and never mutated afterward — it is informational + /// only, so it does not block `contribute` from pushing `amount` past it + /// and does not change `release`/`refund` behavior. 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. /// /// Note: this contract has no visibility into `mergefi-milestones` — @@ -90,6 +93,7 @@ impl EscrowContract { token: Address, amount: i128, deadline: u64, + target: Option, ) -> Result<(), Error> { sponsor.require_auth(); @@ -114,6 +118,7 @@ impl EscrowContract { let escrow = Escrow { token, amount, + target, status: EscrowStatus::Funded, created_at: env.ledger().timestamp(), deadline, diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 91e85bd..94bc7db 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -50,7 +50,14 @@ fn test_fund_and_release_single_recipient() { let contributor = Address::generate(&env); - client.fund(&1u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &1u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); let escrow = client.get_escrow(&1u64); assert_eq!(escrow.amount, 10_000_000_000i128); @@ -82,7 +89,14 @@ fn test_release_with_team_split() { let alice = Address::generate(&env); let bob = Address::generate(&env); - client.fund(&2u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &2u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); // 60/40 split, 5% fee off the top let recipients = vec![&env, (alice.clone(), 6_000u32), (bob.clone(), 4_000u32)]; @@ -111,7 +125,14 @@ fn test_release_distributes_rounding_dust_by_largest_remainder() { let bob = Address::generate(&env); let carol = Address::generate(&env); - client.fund(&8u64, &sponsor, &token_addr, &101i128, &1_000u64); + client.fund( + &8u64, + &sponsor, + &token_addr, + &101i128, + &1_000u64, + &Option::::None, + ); let recipients = vec![ &env, @@ -141,7 +162,14 @@ fn test_release_rejects_invalid_split() { let alice = Address::generate(&env); let bob = Address::generate(&env); - client.fund(&3u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &3u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); // Splits sum to 9000, not 10000 -> invalid let recipients = vec![&env, (alice.clone(), 5_000u32), (bob.clone(), 4_000u32)]; @@ -161,7 +189,14 @@ fn test_double_release_rejected() { asset_client.mint(&sponsor, &10_000_000_000i128); let contributor = Address::generate(&env); - client.fund(&4u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &4u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); let recipients = vec![&env, (contributor.clone(), 10_000u32)]; client.release(&4u64, &recipients); @@ -182,7 +217,14 @@ fn test_unauthorized_release_rejected() { let sponsor = Address::generate(&env); asset_client.mint(&sponsor, &10_000_000_000i128); - client.fund(&5u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &5u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); // Turn auth mocking off; release requires admin auth which is not // provided here, so it must fail with an auth error. @@ -206,7 +248,14 @@ fn test_refund_after_deadline() { env.ledger().set_timestamp(100); - client.fund(&6u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &6u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Before deadline: admin can still force refund (mock_all_auths covers it). env.ledger().set_timestamp(150); @@ -227,7 +276,14 @@ fn test_refund_rejected_if_already_paid() { asset_client.mint(&sponsor, &10_000_000_000i128); let contributor = Address::generate(&env); - client.fund(&7u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + client.fund( + &7u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); let recipients = vec![&env, (contributor.clone(), 10_000u32)]; client.release(&7u64, &recipients); @@ -417,7 +473,14 @@ fn test_fund_requires_sponsor_auth() { // No sponsor auth provided for this specific call. env.set_auths(&[]); - let result = client.try_fund(&9u64, &sponsor, &token_addr, &10_000_000_000i128, &1_000u64); + let result = client.try_fund( + &9u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &1_000u64, + &Option::::None, + ); assert!(result.is_err()); } @@ -433,7 +496,14 @@ fn test_refund_before_deadline_requires_admin_auth() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&10u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &10u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Still before deadline (100 < 200), and no auth provided at all. env.set_auths(&[]); @@ -453,7 +523,14 @@ fn test_refund_after_deadline_is_permissionless() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&11u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &11u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Past the deadline + grace period, and with every auth turned off — not even the // sponsor or admin authorizes this call. `refund` must still succeed: @@ -478,7 +555,14 @@ fn test_extend_deadline_requires_sponsor_auth() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&12u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &12u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Not even the admin can extend on the sponsor's behalf. env.set_auths(&[]); @@ -498,7 +582,14 @@ fn test_extend_deadline_pushes_out_the_permissionless_window() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&13u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &13u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); client.extend_deadline(&13u64, &sponsor, &500u64); assert_eq!(client.get_escrow(&13u64).deadline, 500u64); @@ -524,7 +615,14 @@ fn test_extend_deadline_rejects_non_increasing_deadline() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&14u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &14u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Equal to the current deadline: rejected. let err = client.try_extend_deadline(&14u64, &sponsor, &200u64); @@ -558,6 +656,7 @@ fn test_extend_deadline_rejects_after_paid_or_refunded() { &token_addr, &10_000_000_000i128, &1_000u64, + &Option::::None, ); client.release(&15u64, &vec![&env, (contributor, 10_000u32)]); @@ -592,7 +691,14 @@ fn test_multi_sponsor_refund_returns_exact_contributions_to_each_sponsor() { // Three different sponsors co-fund the same issue with three different // (deliberately unequal) amounts. - client.fund(&100u64, &alice, &token_addr, &3_000i128, &200u64); + client.fund( + &100u64, + &alice, + &token_addr, + &3_000i128, + &200u64, + &Option::::None, + ); client.contribute(&100u64, &bob, &7_000i128); client.contribute(&100u64, &carol, &1_500i128); @@ -634,7 +740,14 @@ fn test_multi_sponsor_release_pays_out_the_combined_total() { asset_client.mint(&alice, &10_000i128); asset_client.mint(&bob, &10_000i128); - client.fund(&101u64, &alice, &token_addr, &4_000i128, &1_000u64); + client.fund( + &101u64, + &alice, + &token_addr, + &4_000i128, + &1_000u64, + &Option::::None, + ); client.contribute(&101u64, &bob, &6_000i128); let maintainer = Address::generate(&env); @@ -659,7 +772,14 @@ fn test_contribute_requires_sponsor_auth() { asset_client.mint(&alice, &10_000i128); asset_client.mint(&bob, &10_000i128); - client.fund(&102u64, &alice, &token_addr, &5_000i128, &1_000u64); + client.fund( + &102u64, + &alice, + &token_addr, + &5_000i128, + &1_000u64, + &Option::::None, + ); // No auth provided for bob's contribution. env.set_auths(&[]); @@ -679,7 +799,14 @@ fn test_contribute_rejects_invalid_amount() { let bob = Address::generate(&env); asset_client.mint(&alice, &10_000i128); - client.fund(&103u64, &alice, &token_addr, &5_000i128, &1_000u64); + client.fund( + &103u64, + &alice, + &token_addr, + &5_000i128, + &1_000u64, + &Option::::None, + ); let err = client.try_contribute(&103u64, &bob, &0i128); assert_eq!(err, Err(Ok(Error::InvalidAmount))); @@ -710,7 +837,14 @@ fn test_contribute_rejects_after_already_paid() { asset_client.mint(&alice, &10_000i128); asset_client.mint(&bob, &10_000i128); - client.fund(&104u64, &alice, &token_addr, &5_000i128, &1_000u64); + client.fund( + &104u64, + &alice, + &token_addr, + &5_000i128, + &1_000u64, + &Option::::None, + ); client.release(&104u64, &vec![&env, (maintainer, 10_000u32)]); let err = client.try_contribute(&104u64, &bob, &1_000i128); @@ -728,7 +862,14 @@ fn test_contribute_rejects_beyond_max_sponsors() { let alice = Address::generate(&env); asset_client.mint(&alice, &10_000i128); - client.fund(&105u64, &alice, &token_addr, &1_000i128, &1_000u64); + client.fund( + &105u64, + &alice, + &token_addr, + &1_000i128, + &1_000u64, + &Option::::None, + ); // MAX_SPONSORS is 20; alice's `fund` call above already used slot 0, so // 19 more `contribute` calls exactly fill the cap. @@ -763,7 +904,14 @@ fn test_extend_deadline_any_contributor_can_extend_not_just_the_original_funder( asset_client.mint(&bob, &10_000i128); env.ledger().set_timestamp(100); - client.fund(&106u64, &alice, &token_addr, &5_000i128, &200u64); + client.fund( + &106u64, + &alice, + &token_addr, + &5_000i128, + &200u64, + &Option::::None, + ); client.contribute(&106u64, &bob, &5_000i128); // Bob (the second contributor, not the original funder) extends. @@ -789,7 +937,14 @@ fn test_extend_deadline_rejects_non_contributor() { let alice = Address::generate(&env); asset_client.mint(&alice, &10_000i128); - client.fund(&107u64, &alice, &token_addr, &5_000i128, &1_000u64); + client.fund( + &107u64, + &alice, + &token_addr, + &5_000i128, + &1_000u64, + &Option::::None, + ); // A stranger who never contributed to this escrow, even with valid // auth for themselves, cannot extend it. @@ -811,7 +966,14 @@ fn test_get_contribution_enumerates_each_contributor() { asset_client.mint(&alice, &10_000i128); asset_client.mint(&bob, &10_000i128); - client.fund(&108u64, &alice, &token_addr, &4_000i128, &1_000u64); + client.fund( + &108u64, + &alice, + &token_addr, + &4_000i128, + &1_000u64, + &Option::::None, + ); client.contribute(&108u64, &bob, &6_000i128); let c0 = client.get_contribution(&108u64, &0u32); @@ -837,7 +999,14 @@ fn test_release_succeeds_in_grace_period() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&200u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &200u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Pass the nominal deadline but stay within the grace period. env.ledger().set_timestamp(200 + crate::GRACE_PERIOD - 1); @@ -868,7 +1037,14 @@ fn test_release_loses_race_to_refund_at_grace_period_boundary() { asset_client.mint(&sponsor, &10_000_000_000i128); env.ledger().set_timestamp(100); - client.fund(&201u64, &sponsor, &token_addr, &10_000_000_000i128, &200u64); + client.fund( + &201u64, + &sponsor, + &token_addr, + &10_000_000_000i128, + &200u64, + &Option::::None, + ); // Reach the exact boundary where the permissionless path opens. env.ledger().set_timestamp(200 + crate::GRACE_PERIOD); @@ -922,6 +1098,7 @@ fn test_extend_deadline_scales_ttl_proportionally_for_a_moderately_far_future_de &token_addr, &10_000_000_000i128, &1_000u64, + &Option::::None, ); // 90 days out — comfortably under the network's own ~1-year ceiling, so @@ -956,6 +1133,7 @@ fn test_extend_deadline_caps_ttl_at_the_network_max_for_a_very_far_future_deadli &token_addr, &10_000_000_000i128, &1_000u64, + &Option::::None, ); // 3 years out — the naive proportional ledger count for this would @@ -987,6 +1165,7 @@ fn test_extend_deadline_never_extends_less_than_the_existing_flat_baseline() { &token_addr, &10_000_000_000i128, &1_000u64, + &Option::::None, ); // Only a few days beyond the current deadline — the proportional target @@ -1015,6 +1194,7 @@ fn test_keep_alive_refreshes_ttl_without_changing_deadline_or_status() { &token_addr, &10_000_000_000i128, &far_future_deadline, + &Option::::None, ); let before = client.get_escrow(&304u64); @@ -1045,3 +1225,48 @@ fn test_keep_alive_rejects_nonexistent_escrow() { let err = client.try_keep_alive(&999u64); assert_eq!(err, Err(Ok(Error::EscrowNotFound))); } + +#[test] +fn test_fund_stores_optional_target_and_contribute_past_it() { + 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 sponsor = Address::generate(&env); + asset_client.mint(&sponsor, &10_000_000_000i128); + + // A target is stored verbatim at fund() time. + client.fund( + &400u64, + &sponsor, + &token_addr, + &3_000i128, + &1_000u64, + &Option::::Some(5_000i128), + ); + let escrow = client.get_escrow(&400u64); + assert_eq!(escrow.target, Option::::Some(5_000i128)); + + // Contributing past the target grows `amount` but never mutates `target`. + let second_sponsor = Address::generate(&env); + asset_client.mint(&second_sponsor, &10_000i128); + client.contribute(&400u64, &second_sponsor, &7_000i128); + + let escrow = client.get_escrow(&400u64); + assert_eq!(escrow.amount, 10_000i128); // raised past the 5_000 target + assert_eq!(escrow.target, Option::::Some(5_000i128)); + assert_eq!(escrow.status, EscrowStatus::Funded); + + // Omitting a target is the default: it is stored as None. + client.fund( + &401u64, + &sponsor, + &token_addr, + &1_000i128, + &1_000u64, + &Option::::None, + ); + assert_eq!(client.get_escrow(&401u64).target, Option::::None); +} diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index 7949410..6705eca 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -13,6 +13,14 @@ pub enum EscrowStatus { pub struct Escrow { pub token: Address, pub amount: i128, + /// Optional funding goal for the (possibly crowdfunded) escrow. Set once + /// at `fund()` time and read-only thereafter — there is deliberately no + /// setter. Purely informational: it does not block `contribute` from + /// pushing `amount` past it, and it does not change `release`/`refund` + /// behavior. It exists so sponsor-facing UI can show an on-chain + /// "raised X of target Y" progress without drifting from on-chain truth. + /// See `docs/escrow-crowdfunding-design.md`. + pub target: Option, pub status: EscrowStatus, pub created_at: u64, pub deadline: u64, diff --git a/docs/escrow-crowdfunding-design.md b/docs/escrow-crowdfunding-design.md index 598b699..452a079 100644 --- a/docs/escrow-crowdfunding-design.md +++ b/docs/escrow-crowdfunding-design.md @@ -34,13 +34,16 @@ into the same `issue_id`: 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. +An optional `target: Option` was introduced on `fund()`, stored on +`Escrow` and read-only thereafter. It is informational only: it does not +block `contribute` from pushing `amount` past it, and it does not change +`release`/`refund` behavior — `escrow.amount` is still simply the running +sum of every accepted contribution (starting with the `fund` call, i.e. the +original sponsor is contribution index `0`). `release` pays out whatever has +accumulated, same as before; `target` merely gives sponsor-facing UI an +on-chain "raised X of target Y" number instead of one tracked off-chain that +can drift from on-chain truth. `None` is the default for callers that don't +set a goal. ## Refund: exact reimbursement, not proportional splitting