diff --git a/crates/contracts/core/src/conditional_escrow.rs b/crates/contracts/core/src/conditional_escrow.rs index 444fb5d..6348459 100644 --- a/crates/contracts/core/src/conditional_escrow.rs +++ b/crates/contracts/core/src/conditional_escrow.rs @@ -71,7 +71,9 @@ impl SkillSyncContract { let fee_bps = Self::get_platform_fee(env.clone()); let now = env.ledger().timestamp(); - let dispute_window = Self::get_dispute_window(env.clone()); + let dispute_window_ledgers = Self::get_dispute_window(env.clone()); + let current_ledger = env.ledger().sequence(); + let dispute_deadline = (current_ledger + dispute_window_ledgers) as u64; let platform_fee = amount .checked_mul(fee_bps as i128) @@ -106,7 +108,7 @@ impl SkillSyncContract { status: SessionStatus::Locked, created_at: now, updated_at: now, - dispute_deadline: now + dispute_window, + dispute_deadline, expires_at: now + crate::ESCROW_DURATION_SECONDS, deadline: env.ledger().sequence() as u64, payer_approved: false, diff --git a/crates/contracts/core/src/events.rs b/crates/contracts/core/src/events.rs index 2b2003a..8946e2c 100644 --- a/crates/contracts/core/src/events.rs +++ b/crates/contracts/core/src/events.rs @@ -107,3 +107,18 @@ pub struct ReferrerFeePaid { /// Ledger timestamp at the moment of claim. pub timestamp: u64, } + +/// Emitted when the admin updates the dispute resolution window. +/// Shows the old and new window values in ledgers. +#[contracttype] +#[derive(Clone, Debug)] +pub struct DisputeWindowUpdated { + /// Previous dispute window value in ledgers. + pub old_window_ledgers: u32, + /// New dispute window value in ledgers. + pub new_window_ledgers: u32, + /// Address of the admin who performed the update. + pub updated_by: Address, + /// Ledger timestamp at the moment of the update. + pub timestamp: u64, +} diff --git a/crates/contracts/core/src/insurance.rs b/crates/contracts/core/src/insurance.rs index f98088c..dfafba1 100644 --- a/crates/contracts/core/src/insurance.rs +++ b/crates/contracts/core/src/insurance.rs @@ -151,7 +151,9 @@ impl SkillSyncContract { let fee_bps = Self::get_platform_fee(env.clone()); let now = env.ledger().timestamp(); - let dispute_window = Self::get_dispute_window(env.clone()); + let dispute_window_ledgers = Self::get_dispute_window(env.clone()); + let current_ledger = env.ledger().sequence(); + let dispute_deadline = (current_ledger + dispute_window_ledgers) as u64; let platform_fee = amount .checked_mul(fee_bps as i128) @@ -189,7 +191,7 @@ impl SkillSyncContract { status: SessionStatus::Locked, created_at: now, updated_at: now, - dispute_deadline: now + dispute_window, + dispute_deadline, expires_at: now + crate::ESCROW_DURATION_SECONDS, deadline: env.ledger().sequence() as u64, payer_approved: false, diff --git a/crates/contracts/core/src/lib.rs b/crates/contracts/core/src/lib.rs index e729fe3..d32b1af 100644 --- a/crates/contracts/core/src/lib.rs +++ b/crates/contracts/core/src/lib.rs @@ -1,5 +1,4 @@ #![no_std] -#![no_std] pub mod conditional_escrow; pub mod dao_dispute; @@ -8,16 +7,13 @@ pub mod storage_archive; pub mod error_codes; -pub use error_codes::{ - AuthError, FinancialError, InitError, SessionError, TimeoutDisputeError, UpgradeError, -}; pub use error_codes::{AuthError, FinancialError, InitError, ReentrancyError, SessionError, TimeoutDisputeError, UpgradeError}; -pub mod errors; +// pub mod errors; // Not used - using Error enum in lib.rs instead pub mod events; pub mod oracle; pub use events::{ - ContractUpgraded, DisputeResolved, OffchainApprovalExecuted, ReferrerFeePaid, + ContractUpgraded, DisputeResolved, DisputeWindowUpdated, OffchainApprovalExecuted, ReferrerFeePaid, SessionApprovedEvent, TreasuryUpdated, }; @@ -29,6 +25,9 @@ use soroban_sdk::{ pub const DISPUTE_WINDOW_MIN_SECONDS: u64 = 60; pub const DISPUTE_WINDOW_MAX_SECONDS: u64 = 30 * 24 * 60 * 60; pub const DEFAULT_DISPUTE_WINDOW_SECONDS: u64 = 24 * 60 * 60; +pub const DEFAULT_DISPUTE_WINDOW_LEDGERS: u32 = 1000; // Default 1000 ledgers +pub const DISPUTE_WINDOW_MIN_LEDGERS: u32 = 10; // Minimum 10 ledgers +pub const DISPUTE_WINDOW_MAX_LEDGERS: u32 = 100_000; // Maximum 100,000 ledgers pub const PLATFORM_FEE_MAX_BPS: u32 = 1000; // 10% pub const MAX_FEE_BPS: u32 = 10_000; // 100% - absolute maximum pub const ESCROW_DURATION_SECONDS: u64 = 7 * 24 * 60 * 60; // Default 7 days @@ -345,43 +344,13 @@ pub enum Error { ExtensionNotProposed = 37, // No extension has been proposed CannotAcceptOwnExtension = 38, // The proposer cannot accept their own extension InvalidSignature = 39, // Invalid cryptographic signature - Reentrancy = 40, // Reentrant call detected + Reentrancy = 40, // Reentrant call detected (Issue #209) ContractPaused = 41, // Contract is paused - SessionNotExpired = 16, - RefundFailed = 17, - NothingToSweep = 18, - UpgradeNotProposed = 19, - UpgradeNotReady = 20, - UpgradeDeadlinePassed = 21, - InvalidTimelock = 22, - InvalidResolutionAmount = 23, - SessionNotDisputed = 24, - ResolutionFeeError = 25, - FeeCalculationOverflow = 26, - NonceAlreadyUsed = 27, - InvalidRating = 28, - ReputationOverflow = 29, - InvalidDisputeState = 30, - InvalidAddress = 31, - InvalidSessionId = 32, - InvalidNote = 33, - AmountTooLarge = 34, - InvalidExtensionDuration = 35, - ExtensionAlreadyProposed = 36, - ExtensionNotProposed = 37, - CannotAcceptOwnExtension = 38, - InvalidSignature = 39, - // Issue #209: Reentrancy detected (code 700 per spec, mapped here as 40) - Reentrancy = 40, - ContractPaused = 41, - // Issue #208: Session expired - SessionExpired = 42, - // Issue #210: Milestone errors - InvalidMilestones = 43, + SessionExpired = 42, // Session expired (Issue #208) + InvalidMilestones = 43, // Issue #210: Milestone errors MilestoneAlreadyReleased = 44, MilestoneIndexOutOfBounds = 45, - // Issue #211: Rating errors - AlreadyRated = 46, + AlreadyRated = 46, // Issue #211: Rating errors SessionNotApproved = 47, } @@ -392,14 +361,14 @@ impl SkillSyncContract { admin: Address, platform_fee_bps: u32, treasury_address: Address, - dispute_window_secs: u64, + dispute_window_ledgers: u32, ) -> Result<(), Error> { if env.storage().instance().has(&DataKey::Admin) { return Err(Error::AlreadyInitialized); } validate_platform_fee_bps(platform_fee_bps)?; - validate_dispute_window(dispute_window_secs)?; + validate_dispute_window_ledgers(dispute_window_ledgers)?; env.storage().instance().set(&DataKey::Admin, &admin); env.storage() @@ -410,7 +379,7 @@ impl SkillSyncContract { .set(&DataKey::Treasury, &treasury_address); env.storage() .instance() - .set(&DataKey::DisputeWindow, &dispute_window_secs); + .set(&DataKey::DisputeWindow, &dispute_window_ledgers); env.storage().instance().set(&DataKey::Version, &VERSION); env.events().publish( @@ -419,7 +388,7 @@ impl SkillSyncContract { admin, platform_fee_bps, treasury_address, - dispute_window_secs, + dispute_window_ledgers, VERSION, ), ); @@ -613,8 +582,9 @@ impl SkillSyncContract { validate_different_addresses(&payer, &payee)?; let now = env.ledger().timestamp(); - let dispute_window = Self::get_dispute_window(env.clone()); - let dispute_deadline = now + dispute_window; + let dispute_window_ledgers = Self::get_dispute_window(env.clone()); + let current_ledger = env.ledger().sequence(); + let dispute_deadline = (current_ledger + dispute_window_ledgers) as u64; let expires_at = now + ESCROW_DURATION_SECONDS; let fee_bps = Self::get_platform_fee(env.clone()); @@ -645,7 +615,6 @@ impl SkillSyncContract { updated_at: now, dispute_deadline, expires_at, - deadline: env.ledger().sequence() as u64, deadline: (env.ledger().sequence() as u64) + (Self::get_max_session_duration(env.clone()) as u64), payer_approved: false, payee_approved: false, @@ -723,9 +692,10 @@ impl SkillSyncContract { } let now = env.ledger().timestamp(); - let dispute_window = Self::get_dispute_window(env.clone()); + let current_ledger = env.ledger().sequence(); - if now <= session.updated_at + dispute_window { + // Check if dispute window has elapsed (using ledger-based deadline) + if current_ledger <= session.dispute_deadline as u32 { return Err(Error::DisputeWindowNotElapsed); } @@ -1200,11 +1170,49 @@ impl SkillSyncContract { id } - pub fn get_dispute_window(env: Env) -> u64 { + pub fn get_dispute_window(env: Env) -> u32 { env.storage() .instance() .get(&DataKey::DisputeWindow) - .unwrap_or(DEFAULT_DISPUTE_WINDOW_SECONDS) + .unwrap_or(DEFAULT_DISPUTE_WINDOW_LEDGERS) + } + + /// Set the dispute resolution window in ledgers. Only callable by admin. + /// Emits DisputeWindowUpdated event. + pub fn set_dispute_window(env: Env, window_ledgers: u32) -> Result<(), Error> { + let admin = read_admin(&env)?; + admin.require_auth(); + Self::require_not_paused(&env)?; + + // Validate the window is within acceptable range + if window_ledgers < DISPUTE_WINDOW_MIN_LEDGERS || window_ledgers > DISPUTE_WINDOW_MAX_LEDGERS { + return Err(Error::InvalidDisputeWindow); + } + + // Get the old value for the event + let old_window_ledgers: u32 = env + .storage() + .instance() + .get(&DataKey::DisputeWindow) + .unwrap_or(DEFAULT_DISPUTE_WINDOW_LEDGERS); + + // Store the new value + env.storage() + .instance() + .set(&DataKey::DisputeWindow, &window_ledgers); + + // Emit the event + env.events().publish( + (Symbol::new(&env, "DisputeWindowUpdated"),), + DisputeWindowUpdated { + old_window_ledgers, + new_window_ledgers: window_ledgers, + updated_by: admin, + timestamp: env.ledger().timestamp(), + }, + ); + + Ok(()) } pub fn get_treasury(env: Env) -> Address { @@ -1375,7 +1383,9 @@ impl SkillSyncContract { payer.require_auth(); let now = env.ledger().timestamp(); - let dispute_window = Self::get_dispute_window(env.clone()); + let dispute_window_ledgers = Self::get_dispute_window(env.clone()); + let current_ledger = env.ledger().sequence(); + let dispute_deadline = (current_ledger + dispute_window_ledgers) as u64; let fee_bps = Self::get_platform_fee(env.clone()); let max_duration = Self::get_max_session_duration(env.clone()); @@ -1414,7 +1424,7 @@ impl SkillSyncContract { status: SessionStatus::Locked, created_at: now, updated_at: now, - dispute_deadline: now + dispute_window, + dispute_deadline, expires_at: now + ESCROW_DURATION_SECONDS, deadline: (env.ledger().sequence() as u64) + (max_duration as u64), payer_approved: false, @@ -1636,9 +1646,6 @@ fn acquire_lock(env: &Env) -> Result<(), Error> { .unwrap_or(false) { return Err(Error::Reentrancy); - if env.storage().instance().get(&DataKey::ReentrancyLock).unwrap_or(false) { - // Issue #209: ReentrancyDetected error code 700 - panic_with_error!(env, ReentrancyError::ReentrancyDetected); } env.storage() .instance() @@ -1669,6 +1676,13 @@ fn validate_dispute_window(seconds: u64) -> Result<(), Error> { Ok(()) } +fn validate_dispute_window_ledgers(ledgers: u32) -> Result<(), Error> { + if ledgers < DISPUTE_WINDOW_MIN_LEDGERS || ledgers > DISPUTE_WINDOW_MAX_LEDGERS { + return Err(Error::InvalidDisputeWindow); + } + Ok(()) +} + fn validate_platform_fee_bps(bps: u32) -> Result<(), Error> { if bps > PLATFORM_FEE_MAX_BPS { return Err(Error::InvalidFeeBps); @@ -1711,4 +1725,3 @@ mod test; #[cfg(test)] mod test_storage_persistence; - diff --git a/crates/contracts/core/src/test.rs b/crates/contracts/core/src/test.rs index c1ae682..215be18 100644 --- a/crates/contracts/core/src/test.rs +++ b/crates/contracts/core/src/test.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use crate::{SkillSyncContract, SkillSyncContractClient, DEFAULT_DISPUTE_WINDOW_SECONDS}; +use crate::{SkillSyncContract, SkillSyncContractClient, DEFAULT_DISPUTE_WINDOW_SECONDS, DEFAULT_DISPUTE_WINDOW_LEDGERS, DISPUTE_WINDOW_MIN_LEDGERS, DISPUTE_WINDOW_MAX_LEDGERS}; use soroban_sdk::{symbol_short, testutils::Address as _, vec, Address, BytesN, Env}; extern crate std; @@ -60,7 +60,7 @@ fn setup_with_admin() -> ( let contract_id = env.register_contract(None, SkillSyncContract); let contract = SkillSyncContractClient::new(&env, &contract_id); - contract.init(&admin, &500, &treasury, &DEFAULT_DISPUTE_WINDOW_SECONDS); + contract.init(&admin, &500, &treasury, &DEFAULT_DISPUTE_WINDOW_LEDGERS); ( env, @@ -102,7 +102,7 @@ fn setup_with_fee( let contract_id = env.register_contract(None, SkillSyncContract); let contract = SkillSyncContractClient::new(&env, &contract_id); - contract.init(&admin, &fee_bps, &treasury, &DEFAULT_DISPUTE_WINDOW_SECONDS); + contract.init(&admin, &fee_bps, &treasury, &DEFAULT_DISPUTE_WINDOW_LEDGERS); ( env, @@ -674,16 +674,16 @@ fn lock_funds_requires_positive_amount() { token, vec, Address, Bytes, Env, IntoVal, Symbol, }; - fn setup_env() -> (Env, SkillSyncContractClient, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register_contract(None, SkillSyncContract); - let client = SkillSyncContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - client.init(&admin, &100, &treasury, &DEFAULT_DISPUTE_WINDOW_SECONDS); - (env, client, admin, treasury) - } +fn setup_env() -> (Env, SkillSyncContractClient, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, SkillSyncContract); + let client = SkillSyncContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.init(&admin, &100, &treasury, &DEFAULT_DISPUTE_WINDOW_LEDGERS); + (env, client, admin, treasury) +} #[test] fn test_auto_refund_success() { @@ -718,21 +718,21 @@ fn lock_funds_requires_positive_amount() { let session = client.get_session(&session_id).unwrap(); assert_eq!(session.status, SessionStatus::Completed); - // 3. Try auto_refund before window (should fail) - let result = client.try_auto_refund(&session_id); - assert!(result.is_err()); + // 3. Try auto_refund before window (should fail) + let result = client.try_auto_refund(&session_id); + assert!(result.is_err()); - // 4. Advance ledger time beyond dispute window - env.ledger().set(LedgerInfo { - timestamp: env.ledger().timestamp() + DEFAULT_DISPUTE_WINDOW_SECONDS + 1, - protocol_version: 20, - sequence_number: 100, - network_id: [0u8; 32], - base_reserve: 100, - min_temp_entry_ttl: 1, - min_persistent_entry_ttl: 1, - max_entry_ttl: 100, - }); + // 4. Advance ledger sequence beyond dispute window + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + 10000, // Advance time as well + protocol_version: 20, + sequence_number: DEFAULT_DISPUTE_WINDOW_LEDGERS + 100, + network_id: [0u8; 32], + base_reserve: 100, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 100, + }); // 5. Run auto_refund client.auto_refund(&session_id); @@ -760,11 +760,11 @@ fn lock_funds_requires_positive_amount() { let session_id = Bytes::from_slice(&env, b"session_locked"); client.lock_funds(&session_id, &payer, &payee, &token_id, &amount, &0); - // Advance time + // Advance ledger sequence beyond dispute window env.ledger().set(LedgerInfo { - timestamp: env.ledger().timestamp() + DEFAULT_DISPUTE_WINDOW_SECONDS + 1, + timestamp: env.ledger().timestamp() + 10000, // Advance time as well protocol_version: 20, - sequence_number: 100, + sequence_number: DEFAULT_DISPUTE_WINDOW_LEDGERS + 100, network_id: [0u8; 32], base_reserve: 100, min_temp_entry_ttl: 1, @@ -775,14 +775,19 @@ fn lock_funds_requires_positive_amount() { // Should fail because status is Locked, not Completed let result = client.try_auto_refund(&session_id); assert!(result.is_err()); - let token_address = env.register_stellar_asset_contract(token_admin.clone()); - let token_client = TokenClient::new(&env, &token_address); - let asset_client = StellarAssetClient::new(&env, &token_address); + } - asset_client.mint(&buyer, &1_000); + #[test] + fn test_upgrade() { + let env = Env::default(); + env.mock_all_auths(); - let contract_id = env.register_contract(None, CoreContract); - let client = CoreContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register_contract(None, SkillSyncContract); + let client = SkillSyncContractClient::new(&env, &contract_id); + client.init(&admin, &100, &treasury, &DEFAULT_DISPUTE_WINDOW_LEDGERS); let admin = Address::generate(&env); client.init(&admin); @@ -1445,3 +1450,120 @@ fn funds_locked_event_emitted() { .unwrap(); assert_eq!(locked_event.0, contract_id); } + +// ============================================================================ +// Test: Dispute Window Configuration +// ============================================================================ + +#[test] +fn test_get_dispute_window_returns_default() { + let (env, contract, _, _, _, _, _, _, _) = setup_with_admin(); + + // Should return default value of 1000 ledgers + let window = contract.get_dispute_window(); + assert_eq!(window, DEFAULT_DISPUTE_WINDOW_LEDGERS); +} + +#[test] +fn test_set_dispute_window_updates_value() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Set new dispute window + let new_window: u32 = 2000; + contract.set_dispute_window(&new_window); + + // Verify it was updated + let window = contract.get_dispute_window(); + assert_eq!(window, new_window); +} + +#[test] +fn test_set_dispute_window_emits_event() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + let old_window = contract.get_dispute_window(); + let new_window: u32 = 1500; + + contract.set_dispute_window(&new_window); + + // Check that DisputeWindowUpdated event was emitted + let events = env.events().all(); + let event = events.iter().find(|e| { + std::format!("{:?}", e.1).contains("DisputeWindowUpdated") + }); + + assert!(event.is_some(), "DisputeWindowUpdated event should be emitted"); +} + +#[test] +#[should_panic(expected = "InvalidDisputeWindow")] +fn test_set_dispute_window_rejects_too_small() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Try to set window below minimum (10 ledgers) + let invalid_window: u32 = 5; + contract.set_dispute_window(&invalid_window); +} + +#[test] +#[should_panic(expected = "InvalidDisputeWindow")] +fn test_set_dispute_window_rejects_too_large() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Try to set window above maximum (100,000 ledgers) + let invalid_window: u32 = 150_000; + contract.set_dispute_window(&invalid_window); +} + +#[test] +#[should_panic(expected = "Unauthorized")] +fn test_set_dispute_window_requires_admin() { + let (env, contract, _, _, buyer, _, _, _, admin) = setup_with_admin(); + + // Try to set dispute window as non-admin (should fail) + env.mock_all_auths_allowing_non_root_auth(); + let new_window: u32 = 2000; + + // This should panic because buyer is not admin + contract.set_dispute_window(&new_window); +} + +#[test] +fn test_set_dispute_window_accepts_minimum_value() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Set to minimum allowed value + let min_window: u32 = DISPUTE_WINDOW_MIN_LEDGERS; + contract.set_dispute_window(&min_window); + + let window = contract.get_dispute_window(); + assert_eq!(window, min_window); +} + +#[test] +fn test_set_dispute_window_accepts_maximum_value() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Set to maximum allowed value + let max_window: u32 = DISPUTE_WINDOW_MAX_LEDGERS; + contract.set_dispute_window(&max_window); + + let window = contract.get_dispute_window(); + assert_eq!(window, max_window); +} + +#[test] +fn test_dispute_window_persists_across_calls() { + let (env, contract, _, _, _, _, _, _, admin) = setup_with_admin(); + + // Set dispute window + let new_window: u32 = 3000; + contract.set_dispute_window(&new_window); + + // Verify it persists + let window1 = contract.get_dispute_window(); + let window2 = contract.get_dispute_window(); + + assert_eq!(window1, new_window); + assert_eq!(window2, new_window); +}