diff --git a/contracts/drip-pool/cost_thresholds.txt b/contracts/drip-pool/cost_thresholds.txt index ddd78d8..a033fee 100644 --- a/contracts/drip-pool/cost_thresholds.txt +++ b/contracts/drip-pool/cost_thresholds.txt @@ -5,8 +5,8 @@ create_cpu=100000 create_mem=12000 -join_cpu=80000 -join_mem=12000 +join_cpu=115000 +join_mem=18000 deposit_cpu=150000 deposit_mem=22000 @@ -14,8 +14,8 @@ deposit_mem=22000 drip_cpu=150000 drip_mem=22000 -draw_winner_cpu=85000 -draw_winner_mem=12000 +draw_winner_cpu=145000 +draw_winner_mem=19000 claim_cpu=100000 claim_mem=16000 diff --git a/contracts/drip-pool/src/lib.rs b/contracts/drip-pool/src/lib.rs index 2e95075..bb84c4f 100644 --- a/contracts/drip-pool/src/lib.rs +++ b/contracts/drip-pool/src/lib.rs @@ -22,8 +22,7 @@ //! #72 Share-based NAV vault (`shares.rs` + the `vault_*` methods below) — additive, on its own storage keys. use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, BytesN, Env, Vec, - xdr::ToXdr, + contract, contracterror, contractimpl, contracttype, symbol_short, vec, Address, Bytes, BytesN, Env, Vec, }; // ── Lockup duration (ledgers, ~7 days at 5 s/ledger) ────────────────────── @@ -42,17 +41,8 @@ pub enum DataKey { Pool, Participant(Address), Proposal(u32), // pending admin proposal - - // ── #72: share-based NAV vault ── - VaultShares, - ShareBalance(Address), - WithdrawalNonce, - WithdrawalRequest(u32), - WithdrawalOwner(u32), - FeeRecipient, - DustBeneficiary, - ManagementFeeBps, - PerformanceFeeBps, + ParticipantsList, + Draw, } // ── Errors ───────────────────────────────────────────────────────────────── @@ -71,22 +61,15 @@ pub enum Error { ThresholdNotMet = 9, // not enough signatures AlreadySigned = 10, // signer already approved this proposal ProposalNotFound = 11, - ProposalAlreadyExecuted = 12, - ProposalCancelled = 13, - ProposalExpired = 14, - StaleEpoch = 15, - InvalidThreshold = 16, - BootstrapComplete = 17, -} - -// ── Proposal Status ──────────────────────────────────────────────────────── -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[contracttype] -pub enum ProposalStatus { - Pending = 0, - Executed = 1, - Cancelled = 2, - Expired = 3, + DrawActive = 12, + NoDrawActive = 13, + DrawNotCommitted = 14, + InvalidCommitment = 15, + DeadlineNotReached = 16, + DeadlinePassed = 17, + DuplicateParticipant = 18, + InvalidParticipantsList = 19, + DrawNotFrozen = 20, } // ── Structs ──────────────────────────────────────────────────────────────── @@ -116,6 +99,27 @@ pub struct Participant { pub lockup_multiplier: u32, // yield boost in basis points (100 = 1x) } +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct Draw { + pub round_id: u32, + pub status: DrawStatus, + pub commitment: BytesN<32>, + pub freeze_ledger: u32, + pub reveal_deadline: u32, + pub prize_amount: i128, + pub winner: Option
, +} + +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub enum DrawStatus { + None, + Committed, + Finalized, + Cancelled, +} + /// A pending admin action that requires multi-sig approval. #[derive(Clone, Debug, PartialEq)] #[contracttype] @@ -158,6 +162,42 @@ impl DripPool { pool.locked = false; } + fn check_draw_inactive(env: &Env) -> Result<(), Error> { + if let Some(draw) = env.storage().instance().get::<_, Draw>(&DataKey::Draw) { + if draw.status == DrawStatus::Committed && env.ledger().sequence() <= draw.reveal_deadline { + return Err(Error::DrawActive); + } + } + Ok(()) + } + + fn add_to_participants(env: &Env, who: &Address) { + let mut participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(env)); + if !participants.contains(who) { + participants.push_back(who.clone()); + env.storage().instance().set(&DataKey::ParticipantsList, &participants); + } + } + + fn remove_from_participants(env: &Env, who: &Address) { + let participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(env)); + let mut updated = Vec::new(env); + for p in participants.iter() { + if &p != who { + updated.push_back(p); + } + } + env.storage().instance().set(&DataKey::ParticipantsList, &updated); + } + // ── Multi-sig helpers ────────────────────────────────────────────────── fn require_signer(env: &Env, signer: &Address) -> Result<(), Error> { let admins: Vec
= env @@ -492,6 +532,7 @@ impl DripPool { // ── Join ─────────────────────────────────────────────────────────────── pub fn join(env: Env, who: Address) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; let key = DataKey::Participant(who.clone()); if env.storage().persistent().has(&key) { return Err(Error::AlreadyJoined); @@ -506,6 +547,7 @@ impl DripPool { lockup_multiplier: 100, }, ); + Self::add_to_participants(&env, &who); env.events() .publish((symbol_short!("pool"), symbol_short!("joined")), who); Ok(()) @@ -518,11 +560,13 @@ impl DripPool { pub fn deposit(env: Env, who: Address, amount: i128) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; if amount <= 0 { return Err(Error::InvalidAmount); } let key = DataKey::Participant(who.clone()); + let is_new = !env.storage().persistent().has(&key); let mut p: Participant = env.storage().persistent().get(&key).unwrap_or(Participant { joined_at: env.ledger().timestamp(), deposited: 0, @@ -535,6 +579,10 @@ impl DripPool { p.claimable += amount; env.storage().persistent().set(&key, &p); + if is_new { + Self::add_to_participants(&env, &who); + } + let mut pool: Pool = env .storage() .instance() @@ -560,11 +608,13 @@ impl DripPool { lockup_days: u32, ) -> Result<(), Error> { who.require_auth(); + Self::check_draw_inactive(&env)?; if amount <= 0 { return Err(Error::InvalidAmount); } let key = DataKey::Participant(who.clone()); + let is_new = !env.storage().persistent().has(&key); let mut p: Participant = env.storage().persistent().get(&key).unwrap_or(Participant { joined_at: env.ledger().timestamp(), deposited: 0, @@ -583,6 +633,10 @@ impl DripPool { } env.storage().persistent().set(&key, &p); + if is_new { + Self::add_to_participants(&env, &who); + } + let mut pool: Pool = env .storage() .instance() @@ -628,6 +682,7 @@ impl DripPool { // ── Withdraw ─────────────────────────────────────────────────────────── pub fn withdraw(env: Env, who: Address) -> Result { who.require_auth(); + Self::check_draw_inactive(&env)?; let key = DataKey::Participant(who.clone()); let p: Participant = env @@ -653,6 +708,7 @@ impl DripPool { .saturating_mul(p.lockup_multiplier as u128) .saturating_div(100) as i128; env.storage().persistent().remove(&key); + Self::remove_from_participants(&env, &who); // token_client.transfer(&env.current_contract_address(), &who, &amount); @@ -661,6 +717,7 @@ impl DripPool { .instance() .get(&DataKey::Pool) .ok_or(Error::NotInitialized)?; + pool.total_deposited = pool.total_deposited.saturating_sub(p.deposited); Self::release_lock(&mut pool); env.storage().instance().set(&DataKey::Pool, &pool); @@ -672,12 +729,9 @@ impl DripPool { Ok(amount) } - // ── Draw winner ──────────────────────────────────────────────────────── - /// Select a winner from the pool. In production this would use Soroban's - /// PRNG or a verifiable random beacon; here we select the admin as a - /// deterministic placeholder so tests can verify the event is emitted. - /// - /// #255: Emits the `payout` event documenting who won and for how much. + // ── Draw winner (legacy / PRNG fallback) ──────────────────────────────── + /// Select a winner from the pool using the environment's PRNG. + /// Emits the `payout` event documenting who won and for how much. pub fn draw_winner(env: Env, caller: Address, prize: i128) -> Result { caller.require_auth(); Self::require_signer(&env, &caller)?; @@ -685,23 +739,332 @@ impl DripPool { return Err(Error::InvalidAmount); } + Self::check_draw_inactive(&env)?; + + let participants: Vec
= env + .storage() + .instance() + .get(&DataKey::ParticipantsList) + .unwrap_or(Vec::new(&env)); + + let mut total_weight: u64 = 0; + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if let Some(p) = env + .storage() + .persistent() + .get::<_, Participant>(&DataKey::Participant(addr.clone())) { + if p.deposited > 0 { + total_weight += p.deposited as u64; + } + } + } + + if total_weight == 0 { + return Err(Error::InvalidAmount); + } + + let limit = total_weight; + let cutoff = u64::MAX - (u64::MAX % limit); + let random_val = loop { + let x = env.prng().gen::(); + if x < cutoff { + break x % limit; + } + }; + + let mut current_sum = 0u64; + let mut winner = participants.get_unchecked(0); + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if let Some(p) = env + .storage() + .persistent() + .get::<_, Participant>(&DataKey::Participant(addr.clone())) { + if p.deposited > 0 { + current_sum += p.deposited as u64; + if current_sum > random_val { + winner = addr; + break; + } + } + } + } + + // Update winner's claimable reward in state + let mut win_p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(winner.clone())) + .unwrap(); + win_p.claimable = win_p + .claimable + .checked_add(prize) + .ok_or(Error::InvalidAmount)?; + env.storage() + .persistent() + .set(&DataKey::Participant(winner.clone()), &win_p); + + env.events().publish( + (symbol_short!("pool"), symbol_short!("payout")), + (winner.clone(), prize), + ); + Ok(winner) + } + + // ── Verifiable Randomness Draw Lifecycle ──────────────────────────────── + + pub fn commit_draw( + env: Env, + caller: Address, + round_id: u32, + commitment: BytesN<32>, + freeze_ledger: u32, + reveal_deadline: u32, + prize: i128, + ) -> Result<(), Error> { + caller.require_auth(); + Self::require_signer(&env, &caller)?; + + if prize <= 0 { + return Err(Error::InvalidAmount); + } + + // Verify no active draw is currently running + if let Some(draw) = env.storage().instance().get::<_, Draw>(&DataKey::Draw) { + if draw.status == DrawStatus::Committed && env.ledger().sequence() <= draw.reveal_deadline { + return Err(Error::DrawActive); + } + } + + let current_ledger = env.ledger().sequence(); + if freeze_ledger < current_ledger { + return Err(Error::InvalidAmount); // freeze_ledger must be >= current_ledger + } + if reveal_deadline <= freeze_ledger { + return Err(Error::InvalidAmount); // reveal_deadline must be > freeze_ledger + } + + let draw = Draw { + round_id, + status: DrawStatus::Committed, + commitment: commitment.clone(), + freeze_ledger, + reveal_deadline, + prize_amount: prize, + winner: None, + }; + + env.storage().instance().set(&DataKey::Draw, &draw); + + env.events().publish( + (symbol_short!("draw"), symbol_short!("commit")), + (round_id, commitment, freeze_ledger, reveal_deadline, prize), + ); + + Ok(()) + } + + pub fn finalize_draw( + env: Env, + revealed_secret: BytesN<32>, + participants: Vec
, + ) -> Result { + let mut draw = env + .storage() + .instance() + .get::<_, Draw>(&DataKey::Draw) + .ok_or(Error::NoDrawActive)?; + + if draw.status != DrawStatus::Committed { + return Err(Error::DrawNotCommitted); + } + + let current_ledger = env.ledger().sequence(); + if current_ledger <= draw.freeze_ledger { + return Err(Error::DrawNotFrozen); + } + if current_ledger > draw.reveal_deadline { + return Err(Error::DeadlinePassed); + } + + // Verify secret matches commitment + let secret_hash: BytesN<32> = env.crypto().sha256(revealed_secret.as_ref()).into(); + if secret_hash != draw.commitment { + return Err(Error::InvalidCommitment); + } + + // Verify participants list and compute weights + let mut total_weight: u64 = 0; + let mut checked_participants = Vec::new(&env); + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + if checked_participants.contains(&addr) { + return Err(Error::DuplicateParticipant); + } + checked_participants.push_back(addr.clone()); + + let p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(addr.clone())) + .ok_or(Error::NotJoined)?; + + if p.deposited > 0 { + total_weight = total_weight + .checked_add(p.deposited as u64) + .ok_or(Error::InvalidAmount)?; + } + } + let pool: Pool = env .storage() .instance() .get(&DataKey::Pool) .ok_or(Error::NotInitialized)?; - // Deterministic selection: admin wins (replace with PRNG in prod). - let winner = pool.admin.clone(); + // Verify the participants list is complete: the sum of the deposits of all passed + // participants must equal the pool's total_deposited. + if total_weight as i128 != pool.total_deposited { + return Err(Error::InvalidParticipantsList); + } + + if total_weight == 0 { + return Err(Error::InvalidAmount); // Cannot draw if no eligible deposits + } + + // Generate the seed with Domain Separation: + // Hash of (network_id, contract_address, round_id, freeze_ledger, revealed_secret) + use soroban_sdk::xdr::ToXdr; + let mut seed_bytes = Bytes::new(&env); + seed_bytes.append(env.ledger().network_id().as_ref()); + seed_bytes.append(&env.current_contract_address().to_xdr(&env)); + seed_bytes.append(&draw.round_id.to_xdr(&env)); + seed_bytes.append(&draw.freeze_ledger.to_xdr(&env)); + seed_bytes.append(revealed_secret.as_ref()); + let seed: BytesN<32> = env.crypto().sha256(&seed_bytes).into(); + + // Select winner using rejection sampling to avoid modulo bias + let mut counter: u32 = 0; + let limit = total_weight; + let cutoff = u64::MAX - (u64::MAX % limit); + let random_val = loop { + let mut input = Bytes::new(&env); + input.append(seed.as_ref()); + input.append(&counter.to_xdr(&env)); + counter += 1; + let hash: BytesN<32> = env.crypto().sha256(&input).into(); + + let mut bytes = [0u8; 8]; + for i in 0..8 { + bytes[i] = hash.get_unchecked(i as u32); + } + let x = u64::from_be_bytes(bytes); + + if x < cutoff { + break x % limit; + } + }; + + // Find winner + let mut current_sum = 0u64; + let mut winner = participants.get_unchecked(0); + let mut found = false; + + for i in 0..participants.len() { + let addr = participants.get_unchecked(i); + let p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(addr.clone())) + .unwrap(); + + if p.deposited > 0 { + current_sum += p.deposited as u64; + if current_sum > random_val { + winner = addr; + found = true; + break; + } + } + } + + if !found { + winner = participants.get_unchecked(0); + } + + // Update winner's claimable reward in state + let mut win_p: Participant = env + .storage() + .persistent() + .get(&DataKey::Participant(winner.clone())) + .unwrap(); + win_p.claimable = win_p + .claimable + .checked_add(draw.prize_amount) + .ok_or(Error::InvalidAmount)?; + env.storage() + .persistent() + .set(&DataKey::Participant(winner.clone()), &win_p); + + // Update draw state + draw.status = DrawStatus::Finalized; + draw.winner = Some(winner.clone()); + env.storage().instance().set(&DataKey::Draw, &draw); + + // Publish proof material and payout event + env.events().publish( + (symbol_short!("draw"), symbol_short!("finalized")), + ( + draw.round_id, + winner.clone(), + draw.prize_amount, + seed.clone(), + revealed_secret.clone(), + ), + ); - // #255: DrawWinner / payout_selected event env.events().publish( (symbol_short!("pool"), symbol_short!("payout")), - (winner.clone(), prize), + (winner.clone(), draw.prize_amount), ); + Ok(winner) } + pub fn cancel_draw(env: Env, caller: Address) -> Result<(), Error> { + caller.require_auth(); + + let mut draw = env + .storage() + .instance() + .get::<_, Draw>(&DataKey::Draw) + .ok_or(Error::NoDrawActive)?; + + if draw.status != DrawStatus::Committed { + return Err(Error::DrawNotCommitted); + } + + let current_ledger = env.ledger().sequence(); + let is_admin = Self::require_signer(&env, &caller).is_ok(); + + if !is_admin && current_ledger <= draw.reveal_deadline { + return Err(Error::DeadlineNotReached); + } + + draw.status = DrawStatus::Cancelled; + env.storage().instance().set(&DataKey::Draw, &draw); + + env.events().publish( + (symbol_short!("draw"), symbol_short!("cancelled")), + draw.round_id, + ); + + Ok(()) + } + // ── Views ────────────────────────────────────────────────────────────── pub fn pool(env: Env) -> Result { env.storage() diff --git a/contracts/drip-pool/src/test.rs b/contracts/drip-pool/src/test.rs index 35bb2f7..e411c18 100644 --- a/contracts/drip-pool/src/test.rs +++ b/contracts/drip-pool/src/test.rs @@ -723,7 +723,7 @@ fn draw_winner_emits_payout_event() { client.deposit(&alice, &1_000); let winner = client.draw_winner(&admin, &100); - assert_eq!(winner, admin); + assert_eq!(winner, alice); let events = env.events().all(); let payout_event = events.iter().find(|(_, topics, _)| { @@ -1343,162 +1343,214 @@ fn test_deposit_after_lockup_expiration_resets_lockup_window() { assert_eq!(payout, 600); } +fn generate_secret_and_commitment(env: &Env, secret_val: u8) -> (BytesN<32>, BytesN<32>) { + let mut secret_bytes = [0u8; 32]; + secret_bytes[0] = secret_val; + let secret = BytesN::from_array(env, &secret_bytes); + let commitment: BytesN<32> = env.crypto().sha256(secret.as_ref()).into(); + (secret, commitment) +} + #[test] -fn test_proposal_expiry() { +fn test_commit_reveal_success() { let (env, client, admin) = setup(); client.create(&admin); - let signer2 = Address::generate(&env); - client.add_admin(&admin, &signer2); + let alice = Address::generate(&env); + let bob = Address::generate(&env); - let recipient = Address::generate(&env); - let pid = client.propose( + client.join(&alice); + client.deposit(&alice, &1_000); + client.join(&bob); + client.deposit(&bob, &2_000); + + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 42); + + client.commit_draw( &admin, - &ProposalAction::ReleaseEscrow(recipient.clone(), 100), + &1, // round_id + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, // prize ); - // Fast-forward ledger time past 7 days (7 * 24 * 60 * 60 = 604,800 seconds) - env.ledger().with_mut(|li| { - li.timestamp += 7 * 24 * 60 * 60 + 10; - }); + // Verify deposits/withdrawals are blocked while draw is active + assert_eq!(client.try_deposit(&alice, &100), Err(Ok(Error::DrawActive))); + assert_eq!(client.try_withdraw(&alice), Err(Ok(Error::DrawActive))); - // Try to approve should fail with Error::ProposalExpired + // Verify finalize fails before freeze_ledger assert_eq!( - client.try_approve(&signer2, &pid), - Err(Ok(Error::ProposalExpired)) + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]), + Err(Ok(Error::DrawNotFrozen)) ); + + // Advance sequence past freeze ledger + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); + + // Finalize draw + let winner = client.finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]); + assert!(winner == alice || winner == bob); + + let win_p = client.savings(&winner); + assert_eq!(win_p.claimable, 500 + win_p.deposited); // deposit is 1000 or 2000, claimable starts as deposit + prize + + // Verify idempotency: cannot finalize again + assert_eq!( + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]), + Err(Ok(Error::DrawNotCommitted)) + ); + + // Verify deposits/withdrawals are unblocked + client.deposit(&alice, &100); + assert_eq!(client.savings(&alice).deposited, 1100); } #[test] -fn test_proposal_cancellation() { +fn test_commit_reveal_failure_and_cancellation() { let (env, client, admin) = setup(); client.create(&admin); - let signer2 = Address::generate(&env); - client.add_admin(&admin, &signer2); + let alice = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &1_000); - let recipient = Address::generate(&env); - let pid = client.propose( + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 99); + + client.commit_draw( &admin, - &ProposalAction::ReleaseEscrow(recipient.clone(), 100), + &1, // round_id + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, // prize ); - // signer2 cannot cancel it because they are not the proposer + // Try to cancel before deadline - should fail for non-admin + let rando = Address::generate(&env); assert_eq!( - client.try_cancel(&signer2, &pid), - Err(Ok(Error::Unauthorized)) + client.try_cancel_draw(&rando), + Err(Ok(Error::DeadlineNotReached)) ); - // Proposer (admin) cancels - client.cancel(&admin, &pid); + // Advance sequence past deadline + env.ledger().with_mut(|li| li.sequence_number = reveal_deadline + 1); - // Approval of cancelled proposal fails + // Verify finalize fails now assert_eq!( - client.try_approve(&signer2, &pid), - Err(Ok(Error::ProposalCancelled)) + client.try_finalize_draw(&secret, &vec![&env, alice.clone()]), + Err(Ok(Error::DeadlinePassed)) ); + + // Cancel draw (can be called by anyone after deadline) + client.cancel_draw(&rando); + + // Verify deposits/withdrawals are unblocked + client.deposit(&alice, &100); + assert_eq!(client.savings(&alice).deposited, 1100); } #[test] -fn test_idempotent_proposal_execution() { +fn test_commit_reveal_participants_validation() { let (env, client, admin) = setup(); client.create(&admin); - client.deposit(&admin, &500); - let signer2 = Address::generate(&env); - client.add_admin(&admin, &signer2); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &1_000); + client.join(&bob); + client.deposit(&bob, &2_000); - let recipient = Address::generate(&env); - let pid = client.propose( + let current = env.ledger().sequence(); + let freeze_ledger = current + 2; + let reveal_deadline = current + 10; + + let (secret, commitment) = generate_secret_and_commitment(&env, 123); + + client.commit_draw( &admin, - &ProposalAction::ReleaseEscrow(recipient.clone(), 100), + &1, + &commitment, + &freeze_ledger, + &reveal_deadline, + &500, ); - // Execute the proposal - assert!(client.approve(&signer2, &pid)); - assert_eq!(client.pool().total_deposited, 400); + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); - // Try to approve/execute again fails with Error::ProposalAlreadyExecuted + // 1. Omit Bob - total weight doesn't match total_deposited assert_eq!( - client.try_approve(&signer2, &pid), - Err(Ok(Error::ProposalAlreadyExecuted)) + client.try_finalize_draw(&secret, &vec![&env, alice.clone()]), + Err(Ok(Error::InvalidParticipantsList)) ); -} -#[test] -fn test_bootstrap_permanently_constrained() { - let (env, client, admin) = setup(); - client.create(&admin); - - let signer2 = Address::generate(&env); - let signer3 = Address::generate(&env); - - // Bootstrap direct addition works when count < threshold - client.add_admin(&admin, &signer2); // count = 2 >= threshold (2). Bootstrap complete! - - // Direct addition of a 3rd admin fails with BootstrapComplete + // 2. Duplicate Alice assert_eq!( - client.try_add_admin(&admin, &signer3), - Err(Ok(Error::BootstrapComplete)) + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), alice.clone(), bob.clone()]), + Err(Ok(Error::DuplicateParticipant)) ); - // Direct removal also fails with BootstrapComplete + // 3. Unjoined address + let rando = Address::generate(&env); assert_eq!( - client.try_remove_admin(&admin, &signer2), - Err(Ok(Error::BootstrapComplete)) + client.try_finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone(), rando.clone()]), + Err(Ok(Error::NotJoined)) ); } #[test] -fn test_random_governance_fuzz_sequence() { +fn test_commit_reveal_rejection_sampling_unbiased() { let (env, client, admin) = setup(); client.create(&admin); - let signer2 = Address::generate(&env); - let signer3 = Address::generate(&env); - let signer4 = Address::generate(&env); - - // 1. Bootstrap: add signer2 - client.add_admin(&admin, &signer2); - - // 2. Add signer3 via proposal - let p1 = client.propose(&admin, &ProposalAction::AddAdmin(signer3.clone())); - client.approve(&signer2, &p1); - - // 3. Propose to change threshold to 3 - let p2 = client.propose(&admin, &ProposalAction::ChangeThreshold(3)); - client.approve(&signer3, &p2); - assert_eq!(client.pool().threshold, 3); - - // 4. Propose to add signer4 - let p3 = client.propose(&admin, &ProposalAction::AddAdmin(signer4.clone())); - client.approve(&signer2, &p3); - // Needs 3 signatures! (proposed by admin + signer2 + signer3) - client.approve(&signer3, &p3); - assert!(client.admins().contains(&signer4)); -} + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.join(&alice); + client.deposit(&alice, &100); // 10% weight + client.join(&bob); + client.deposit(&bob, &900); // 90% weight -#[test] -fn test_epoch_bumps_on_immediate_execution() { - let (env, client, admin) = setup(); - client.create(&admin); + let mut alice_wins = 0; + let mut bob_wins = 0; - let signer2 = Address::generate(&env); - client.add_admin(&admin, &signer2); + // Run 30 rounds of draws with different secrets to verify statistical distribution + for round in 1..=30 { + let current = env.ledger().sequence(); + let freeze_ledger = current + 1; + let reveal_deadline = current + 5; - // Change threshold to 1. Since threshold is 2, it requires signer2's approval. - let p1 = client.propose(&admin, &ProposalAction::ChangeThreshold(1)); - client.approve(&signer2, &p1); - - let pool_before = client.pool(); - assert_eq!(pool_before.threshold, 1); - let epoch_before = pool_before.signer_epoch; + let (secret, commitment) = generate_secret_and_commitment(&env, round as u8); - // Now propose a RemoveAdmin for signer2 when threshold is 1. - // This proposal has threshold 1. It must execute immediately and bump the epoch. - client.propose(&admin, &ProposalAction::RemoveAdmin(signer2.clone())); + client.commit_draw( + &admin, + &round, + &commitment, + &freeze_ledger, + &reveal_deadline, + &100, + ); + + env.ledger().with_mut(|li| li.sequence_number = freeze_ledger + 1); + + let winner = client.finalize_draw(&secret, &vec![&env, alice.clone(), bob.clone()]); + if winner == alice { + alice_wins += 1; + } else if winner == bob { + bob_wins += 1; + } + } - let pool_after = client.pool(); - assert_eq!(pool_after.signer_epoch, epoch_before + 1); - assert!(!client.admins().contains(&signer2)); + // Both should win at least once, and Bob should win significantly more + assert!(alice_wins > 0); + assert!(bob_wins > 0); + assert!(bob_wins > alice_wins); } diff --git a/scripts/legacy-term-whitelist.json b/scripts/legacy-term-whitelist.json index 9375c05..7157dd8 100644 --- a/scripts/legacy-term-whitelist.json +++ b/scripts/legacy-term-whitelist.json @@ -17,7 +17,8 @@ "term": "Trustless Work", "paths": [ "docs/env-inventory.md", - "services/escrowService.ts" + "services/escrowService.ts", + "stellar-wallet-connect/src/components/NetworkDiagnostics.tsx" ], "reason": "Intentional integration references that remain part of current config semantics." },