diff --git a/contracts/disputes/src/lib.rs b/contracts/disputes/src/lib.rs index bd182c42..f4d0e481 100644 --- a/contracts/disputes/src/lib.rs +++ b/contracts/disputes/src/lib.rs @@ -72,6 +72,7 @@ pub struct EscalationRecord { pub stake: u32, pub turnout: u32, pub community_outcome: DisputeOutcome, + pub council_voters: soroban_sdk::Vec, pub council_votes: u32, pub uphold_votes: u32, pub overturn_votes: u32, @@ -229,6 +230,7 @@ impl DisputesContract { stake, turnout, community_outcome, + council_voters: soroban_sdk::Vec::new(&_env), council_votes: 0, uphold_votes: 0, overturn_votes: 0, @@ -243,7 +245,8 @@ impl DisputesContract { pub fn council_vote( env: Env, policy: EscalationPolicy, - record: EscalationRecord, + mut record: EscalationRecord, + voter: soroban_sdk::Address, vote: u32, ) -> EscalationRecord { let mut record: EscalationRecord = env @@ -254,6 +257,13 @@ impl DisputesContract { if record.resolved { panic!("escalation already resolved"); } + if record + .council_voters + .iter() + .any(|member| member == voter.clone()) + { + panic!("council member has already voted"); + } match vote { COUNCIL_VOTE_ABSTAIN => {} COUNCIL_VOTE_UPHOLD => { @@ -264,6 +274,7 @@ impl DisputesContract { } _ => panic!("invalid council vote"), } + record.council_voters.push_back(voter); record.council_votes += 1; if record.uphold_votes >= policy.council_threshold { record.resolved = true; diff --git a/contracts/disputes/tests/dispute_storage.rs b/contracts/disputes/tests/dispute_storage.rs index cfa07093..fdf05936 100644 --- a/contracts/disputes/tests/dispute_storage.rs +++ b/contracts/disputes/tests/dispute_storage.rs @@ -1,8 +1,8 @@ use disputes::{ - DisputeOutcome, DisputeStatus, DisputesContract, DisputesContractClient, - EscalationPolicy, COUNCIL_VOTE_UPHOLD, + DisputeOutcome, DisputeStatus, DisputesContract, DisputesContractClient, EscalationPolicy, + COUNCIL_VOTE_UPHOLD, }; -use soroban_sdk::{Env, Symbol}; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; #[test] fn dispute_lifecycle_is_persisted_and_uses_unique_ids() { @@ -37,35 +37,25 @@ fn dispute_lifecycle_is_persisted_and_uses_unique_ids() { } #[test] -fn council_votes_accumulate_in_persisted_escalation_record() { +#[should_panic(expected = "council member has already voted")] +fn council_vote_rejects_duplicate_voter() { let env = Env::default(); let contract_id = env.register(DisputesContract, ()); let client = DisputesContractClient::new(&env, &contract_id); - let market_id = Symbol::new(&env, "MARKET1"); + let voter = Address::generate(&env); let policy = EscalationPolicy { - min_stake: 100, + min_stake: 1, quorum: 10, council_threshold: 2, }; - - let escalation = client.escalate_dispute( + let record = client.escalate_dispute( &policy, - &market_id, - &100, - &9, - &DisputeOutcome::ChallengerWins, + &Symbol::new(&env, "MARKET"), + &1, + &0, + &DisputeOutcome::Escalated, ); - assert_eq!(client.get_escalation(&market_id), Some(escalation.clone())); - - let first_vote = client.council_vote(&policy, &escalation, &COUNCIL_VOTE_UPHOLD); - assert_eq!(first_vote.council_votes, 1); - assert_eq!(first_vote.uphold_votes, 1); - assert!(!first_vote.resolved); - // A stale caller-supplied record must not discard the vote already stored. - let second_vote = client.council_vote(&policy, &escalation, &COUNCIL_VOTE_UPHOLD); - assert_eq!(second_vote.council_votes, 2); - assert_eq!(second_vote.uphold_votes, 2); - assert!(second_vote.resolved); - assert_eq!(client.get_escalation(&market_id), Some(second_vote)); + let record = client.council_vote(&policy, &record, &voter, &COUNCIL_VOTE_UPHOLD); + client.council_vote(&policy, &record, &voter, &COUNCIL_VOTE_UPHOLD); } \ No newline at end of file diff --git a/contracts/fees/src/lib.rs b/contracts/fees/src/lib.rs index cb7fbb57..9a6531d6 100644 --- a/contracts/fees/src/lib.rs +++ b/contracts/fees/src/lib.rs @@ -293,6 +293,7 @@ impl FeesContract { /// - `ContractError::Unauthorized` if caller is not the admin. /// - `ContractError::FeePercentageTooHigh` if platform_fee_percentage > 10_000. /// - `ContractError::InvalidInput` if any fee value is negative. + /// - `ContractError::NoPendingFeeCommit` if no commitment exists. pub fn update_fee_config( env: Env, admin: Address, @@ -308,6 +309,10 @@ impl FeesContract { // Ensure fees are not paused for config updates (consistency) Self::assert_fees_not_paused(&env)?; + if Self::get_pending_fee_commit_internal(&env).is_none() { + return Err(ContractError::NoPendingFeeCommit); + } + Self::queue_fee_config(&env, &admin, new_config, DEFAULT_TIMELOCK_SECONDS) } diff --git a/contracts/fees/tests/auth_boundary.rs b/contracts/fees/tests/auth_boundary.rs index ac3f2846..b4486458 100644 --- a/contracts/fees/tests/auth_boundary.rs +++ b/contracts/fees/tests/auth_boundary.rs @@ -165,6 +165,9 @@ fn test_update_fee_config_succeeds_with_admin_auth() { let client = register_and_initialize(&env, &admin); let config = default_fee_config(); + let hash = soroban_sdk::BytesN::from_array(&env, &[0x11u8; 32]); + client.commit_fee_config(&admin, &hash); + let _ = env.auths(); client.update_fee_config(&admin, &config); let auths = env.auths(); @@ -218,7 +221,39 @@ fn test_update_fee_config_rejects_zero_minimum_when_fees_enabled() { assert!(matches!(result, Err(Ok(ContractError::InvalidInput)))); config.fees_enabled = false; + let hash = soroban_sdk::BytesN::from_array(&env, &[0x22u8; 32]); + client.commit_fee_config(&admin, &hash); + client.update_fee_config(&admin, &config); +} + +#[test] +fn test_update_fee_config_requires_commit_and_apply_timelock() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + let config = default_fee_config(); + + let no_commit = client.try_update_fee_config(&admin, &config); + assert!(matches!( + no_commit, + Err(Ok(ContractError::NoPendingFeeCommit)) + )); + + let hash = soroban_sdk::BytesN::from_array(&env, &[0x33u8; 32]); + client.commit_fee_config(&admin, &hash); client.update_fee_config(&admin, &config); + + let early_apply = client.try_apply_fee_update(&admin); + assert!(matches!( + early_apply, + Err(Ok(ContractError::FeeRevealTooEarly)) + )); + + env.ledger().with_mut(|ledger| { + ledger.timestamp += 86_401; + }); + client.apply_fee_update(&admin); + assert_eq!(client.get_fee_config(), config); } // ============================================================ @@ -756,6 +791,8 @@ fn test_all_state_changing_entrypoints_succeed_with_proper_auth() { let config = default_fee_config(); + let hash = soroban_sdk::BytesN::from_array(&env, &[0x44u8; 32]); + client.commit_fee_config(&admin, &hash); assert!( client.try_update_fee_config(&admin, &config).is_ok(), "Admin should invoke update_fee_config" @@ -846,6 +883,8 @@ fn test_pending_fee_config_inspection_and_apply() { let new_cfg = default_fee_config(); let original_cfg = client.get_fee_config(); assert_ne!(new_cfg, original_cfg); + let hash = soroban_sdk::BytesN::from_array(&env, &[0x55u8; 32]); + client.commit_fee_config(&admin, &hash); client.update_fee_config(&admin, &new_cfg); assert_eq!(client.get_fee_config(), original_cfg); diff --git a/contracts/monitor/tests/limits.rs b/contracts/monitor/tests/limits.rs index 1b0bd653..2f54c960 100644 --- a/contracts/monitor/tests/limits.rs +++ b/contracts/monitor/tests/limits.rs @@ -247,13 +247,25 @@ fn test_record_bet_after_remove_succeeds() { // --------------------------------------------------------------------------- #[test] -fn test_record_bet_exceeds_cap_returns_error() { +fn test_record_bets_exactly_at_cap_then_rejects_next() { let (_env, setup) = setup(); setup .client - .set_caps(&setup.admin, &CapType::Bets, &2u32); - setup.client.record_bet(&setup.user1); - setup.client.record_bet(&setup.user1); + .set_caps(&setup.admin, &CapType::Bets, &3u32); + let caps = setup.client.get_caps(); + + for expected_count in 1..=caps.max_bets { + assert_eq!( + setup.client.record_bet(&setup.user1), + expected_count, + "recording bet {expected_count} should succeed up to max_bets" + ); + } + assert_eq!( + setup.client.get_account_state(&setup.user1).bets, + caps.max_bets + ); + let result = setup.client.try_record_bet(&setup.user1); assert_eq!(result, Ok(Err(MonitorError::BetCapExceeded))); } diff --git a/contracts/validators/tests/auth_boundary.rs b/contracts/validators/tests/auth_boundary.rs index eb286cf4..e491822a 100644 --- a/contracts/validators/tests/auth_boundary.rs +++ b/contracts/validators/tests/auth_boundary.rs @@ -15,7 +15,7 @@ use soroban_sdk::{ testutils::{Address as _, Ledger, LedgerInfo}, Address, Env, }; -use validators::{ValidatorsContract, ValidatorsContractClient}; +use validators::{ValidatorError, ValidatorsContract, ValidatorsContractClient}; // --------------------------------------------------------------------------- // Test harness @@ -238,6 +238,33 @@ fn test_update_stake_requires_auth_success() { } } +#[test] +fn test_update_stake_enforces_inclusive_min_and_max_bounds() { + let env = Env::default(); + let ts = setup_with_validator(&env); + + assert_eq!( + ts.client.try_update_stake(&ts.validator, &MIN_STAKE), + Ok(Ok(())), + "stake exactly at min_stake should succeed" + ); + assert_eq!( + ts.client.try_update_stake(&ts.validator, &(MIN_STAKE - 1)), + Ok(Err(ValidatorError::StakeTooLow)), + "stake below min_stake should fail" + ); + assert_eq!( + ts.client.try_update_stake(&ts.validator, &MAX_STAKE), + Ok(Ok(())), + "stake exactly at max_stake should succeed" + ); + assert_eq!( + ts.client.try_update_stake(&ts.validator, &(MAX_STAKE + 1)), + Ok(Err(ValidatorError::StakeTooHigh)), + "stake above max_stake should fail" + ); +} + // =========================================================================== // set_validator_active // ===========================================================================