diff --git a/contracts/payment-vault-contract/src/lib.rs b/contracts/payment-vault-contract/src/lib.rs index 651c2c4..46023bf 100644 --- a/contracts/payment-vault-contract/src/lib.rs +++ b/contracts/payment-vault-contract/src/lib.rs @@ -1,5 +1,4 @@ #![no_std] - mod contract; mod error; mod events; @@ -44,8 +43,8 @@ impl PaymentVaultContract { contract::set_my_rate(&env, &expert, rate_per_second) } - /// Book a session with an expert - /// User deposits tokens upfront based on rate_per_second * max_duration + /// Book a session with an expert. + /// User deposits tokens upfront based on rate_per_second * max_duration. pub fn book_session( env: Env, user: Address, @@ -55,8 +54,8 @@ impl PaymentVaultContract { contract::book_session(&env, &user, &expert, max_duration) } - /// Finalize a session (Oracle-only) - /// Calculates payments based on actual duration and processes refunds + /// Finalize a session (Oracle-only). + /// Calculates payments based on actual duration and processes refunds. pub fn finalize_session( env: Env, booking_id: u64, @@ -65,8 +64,8 @@ impl PaymentVaultContract { contract::finalize_session(&env, booking_id, actual_duration) } - /// Reclaim funds from a stale booking (User-only) - /// Users can reclaim their deposit if the booking has been pending for more than 24 hours + /// Reclaim funds from a stale booking (User-only). + /// Users can reclaim their deposit if the booking has been pending for more than 24 hours. pub fn reclaim_stale_session( env: Env, user: Address, @@ -75,23 +74,40 @@ impl PaymentVaultContract { contract::reclaim_stale_session(&env, &user, booking_id) } - /// Reject a pending session (Expert-only) - /// Experts can reject a pending booking, instantly refunding the user + /// Reject a pending session (Expert-only). + /// Experts can reject a pending booking, instantly refunding the user. pub fn reject_session(env: Env, expert: Address, booking_id: u64) -> Result<(), VaultError> { contract::reject_session(&env, &expert, booking_id) } - /// Get all booking IDs for a specific user - pub fn get_user_bookings(env: Env, user: Address) -> Vec { - storage::get_user_bookings(&env, &user) + /// Get a paginated list of booking IDs for a specific user. + /// `start_index` is 0-based. Returns at most `limit` booking IDs. + pub fn get_user_bookings(env: Env, user: Address, start_index: u32, limit: u32) -> Vec { + storage::get_user_bookings_paginated(&env, &user, start_index, limit) + } + + /// Get the total number of bookings a user has ever made. + pub fn get_user_booking_count(env: Env, user: Address) -> u32 { + storage::get_user_booking_count(&env, &user) + } + + /// Get a paginated list of booking IDs for a specific expert. + /// `start_index` is 0-based. Returns at most `limit` booking IDs. + pub fn get_expert_bookings( + env: Env, + expert: Address, + start_index: u32, + limit: u32, + ) -> Vec { + storage::get_expert_bookings_paginated(&env, &expert, start_index, limit) } - /// Get all booking IDs for a specific expert - pub fn get_expert_bookings(env: Env, expert: Address) -> Vec { - storage::get_expert_bookings(&env, &expert) + /// Get the total number of bookings an expert has ever received. + pub fn get_expert_booking_count(env: Env, expert: Address) -> u32 { + storage::get_expert_booking_count(&env, &expert) } - /// Get booking details by booking ID (read-only) + /// Get booking details by booking ID (read-only). pub fn get_booking(env: Env, booking_id: u64) -> Option { storage::get_booking(&env, booking_id) } diff --git a/contracts/payment-vault-contract/src/storage.rs b/contracts/payment-vault-contract/src/storage.rs index e82c4b9..9ce1fdd 100644 --- a/contracts/payment-vault-contract/src/storage.rs +++ b/contracts/payment-vault-contract/src/storage.rs @@ -7,12 +7,17 @@ pub enum DataKey { Admin, Token, Oracle, - Booking(u64), // Booking ID -> BookingRecord - BookingCounter, // Counter for generating unique booking IDs - UserBookings(Address), // User Address -> Vec of booking IDs - ExpertBookings(Address), // Expert Address -> Vec of booking IDs - IsPaused, // Circuit breaker flag - ExpertRate(Address), // Expert Address -> rate per second (i128) + Booking(u64), // Booking ID -> BookingRecord + BookingCounter, // Counter for generating unique booking IDs + // ── Indexed User Booking List ────────────────────────────────────────── + // Replaces the old Vec approach with O(1) per-write composite keys. + UserBooking(Address, u32), // (user, index) -> booking_id + UserBookingCount(Address), // user -> total count (u32) + // ── Indexed Expert Booking List ──────────────────────────────────────── + ExpertBooking(Address, u32), // (expert, index) -> booking_id + ExpertBookingCount(Address), // expert -> total count (u32) + IsPaused, // Circuit breaker flag + ExpertRate(Address), // Expert Address -> rate per second (i128) } // --- Admin --- @@ -93,47 +98,102 @@ pub fn update_booking_status(env: &Env, booking_id: u64, status: BookingStatus) } } -// --- User and Expert Booking Lists --- -pub fn add_booking_to_user_list(env: &Env, user: &Address, booking_id: u64) { - let mut user_bookings: soroban_sdk::Vec = env - .storage() - .persistent() - .get(&DataKey::UserBookings(user.clone())) - .unwrap_or(soroban_sdk::Vec::new(env)); - - user_bookings.push_back(booking_id); +// --- User Booking List (O(1) indexed storage) --- +/// Returns how many bookings a user has booked in total. +pub fn get_user_booking_count(env: &Env, user: &Address) -> u32 { env.storage() .persistent() - .set(&DataKey::UserBookings(user.clone()), &user_bookings); + .get(&DataKey::UserBookingCount(user.clone())) + .unwrap_or(0u32) } -pub fn add_booking_to_expert_list(env: &Env, expert: &Address, booking_id: u64) { - let mut expert_bookings: soroban_sdk::Vec = env - .storage() +/// Appends a booking_id to the user's list in O(1) — no Vec load/save. +pub fn add_booking_to_user_list(env: &Env, user: &Address, booking_id: u64) { + let count = get_user_booking_count(env, user); + // Store the new booking_id at slot `count` (0-indexed) + env.storage() .persistent() - .get(&DataKey::ExpertBookings(expert.clone())) - .unwrap_or(soroban_sdk::Vec::new(env)); - - expert_bookings.push_back(booking_id); - + .set(&DataKey::UserBooking(user.clone(), count), &booking_id); + // Increment the counter env.storage() .persistent() - .set(&DataKey::ExpertBookings(expert.clone()), &expert_bookings); + .set(&DataKey::UserBookingCount(user.clone()), &(count + 1)); +} + +/// Returns a paginated slice of booking IDs for a user. +/// `start_index` is 0-based; returns at most `limit` items. +pub fn get_user_bookings_paginated( + env: &Env, + user: &Address, + start_index: u32, + limit: u32, +) -> soroban_sdk::Vec { + let count = get_user_booking_count(env, user); + let mut result = soroban_sdk::Vec::new(env); + + let end = (start_index + limit).min(count); + let mut i = start_index; + while i < end { + if let Some(booking_id) = env + .storage() + .persistent() + .get::(&DataKey::UserBooking(user.clone(), i)) + { + result.push_back(booking_id); + } + i += 1; + } + + result } -pub fn get_user_bookings(env: &Env, user: &Address) -> soroban_sdk::Vec { +// --- Expert Booking List (O(1) indexed storage) --- + +/// Returns how many bookings an expert has in total. +pub fn get_expert_booking_count(env: &Env, expert: &Address) -> u32 { env.storage() .persistent() - .get(&DataKey::UserBookings(user.clone())) - .unwrap_or(soroban_sdk::Vec::new(env)) + .get(&DataKey::ExpertBookingCount(expert.clone())) + .unwrap_or(0u32) } -pub fn get_expert_bookings(env: &Env, expert: &Address) -> soroban_sdk::Vec { +/// Appends a booking_id to the expert's list in O(1) — no Vec load/save. +pub fn add_booking_to_expert_list(env: &Env, expert: &Address, booking_id: u64) { + let count = get_expert_booking_count(env, expert); env.storage() .persistent() - .get(&DataKey::ExpertBookings(expert.clone())) - .unwrap_or(soroban_sdk::Vec::new(env)) + .set(&DataKey::ExpertBooking(expert.clone(), count), &booking_id); + env.storage() + .persistent() + .set(&DataKey::ExpertBookingCount(expert.clone()), &(count + 1)); +} + +/// Returns a paginated slice of booking IDs for an expert. +/// `start_index` is 0-based; returns at most `limit` items. +pub fn get_expert_bookings_paginated( + env: &Env, + expert: &Address, + start_index: u32, + limit: u32, +) -> soroban_sdk::Vec { + let count = get_expert_booking_count(env, expert); + let mut result = soroban_sdk::Vec::new(env); + + let end = (start_index + limit).min(count); + let mut i = start_index; + while i < end { + if let Some(booking_id) = env + .storage() + .persistent() + .get::(&DataKey::ExpertBooking(expert.clone(), i)) + { + result.push_back(booking_id); + } + i += 1; + } + + result } // --- Expert Rates --- diff --git a/contracts/payment-vault-contract/src/test.rs b/contracts/payment-vault-contract/src/test.rs index ea0e77d..f776e48 100644 --- a/contracts/payment-vault-contract/src/test.rs +++ b/contracts/payment-vault-contract/src/test.rs @@ -45,17 +45,13 @@ fn test_partial_duration_scenario() { let expert = Address::generate(&env); let oracle = Address::generate(&env); - // Create token contract and mint tokens to user let token_admin = Address::generate(&env); let token = create_token_contract(&env, &token_admin); token.mint(&user, &10_000); - // Initialize vault let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session: rate = 10 tokens/second, max_duration = 100 seconds - // Total deposit = 10 * 100 = 1000 tokens let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -63,17 +59,14 @@ fn test_partial_duration_scenario() { client.book_session(&user, &expert, &max_duration) }; - // Verify user's balance decreased assert_eq!(token.balance(&user), 9_000); assert_eq!(token.balance(&client.address), 1_000); - // Oracle finalizes with 50% of booked time (50 seconds) let actual_duration = 50_u64; client.finalize_session(&booking_id, &actual_duration); - // Expected: expert_pay = 10 * 50 = 500, refund = 1000 - 500 = 500 assert_eq!(token.balance(&expert), 500); - assert_eq!(token.balance(&user), 9_500); // 9000 + 500 refund + assert_eq!(token.balance(&user), 9_500); assert_eq!(token.balance(&client.address), 0); } @@ -94,7 +87,6 @@ fn test_full_duration_no_refund() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -102,13 +94,11 @@ fn test_full_duration_no_refund() { client.book_session(&user, &expert, &max_duration) }; - // Oracle finalizes with full duration (100 seconds) let actual_duration = 100_u64; client.finalize_session(&booking_id, &actual_duration); - // Expected: expert_pay = 10 * 100 = 1000, refund = 0 assert_eq!(token.balance(&expert), 1_000); - assert_eq!(token.balance(&user), 9_000); // No refund + assert_eq!(token.balance(&user), 9_000); assert_eq!(token.balance(&client.address), 0); } @@ -136,12 +126,10 @@ fn test_double_finalization_protection() { client.book_session(&user, &expert, &max_duration) }; - // First finalization succeeds let actual_duration = 50_u64; let result = client.try_finalize_session(&booking_id, &actual_duration); assert!(result.is_ok()); - // Second finalization should fail (booking no longer Pending) let result_duplicate = client.try_finalize_session(&booking_id, &actual_duration); assert!(result_duplicate.is_err()); } @@ -170,18 +158,14 @@ fn test_oracle_authorization_enforcement() { client.book_session(&user, &expert, &max_duration) }; - // Clear all mocked auths to test Oracle authorization env.set_auths(&[]); - // Try to finalize without any auth (should fail with auth error) let result = client.try_finalize_session(&booking_id, &50); assert!(result.is_err()); - // Finalize with Oracle auth (should succeed) env.mock_all_auths(); client.finalize_session(&booking_id, &50); - // Verify finalization succeeded assert_eq!(token.balance(&expert), 500); } @@ -209,13 +193,11 @@ fn test_zero_duration_finalization() { client.book_session(&user, &expert, &max_duration) }; - // Oracle finalizes with 0 duration (session cancelled) let actual_duration = 0_u64; client.finalize_session(&booking_id, &actual_duration); - // Expected: expert_pay = 0, full refund to user assert_eq!(token.balance(&expert), 0); - assert_eq!(token.balance(&user), 10_000); // Full refund + assert_eq!(token.balance(&user), 10_000); assert_eq!(token.balance(&client.address), 0); } @@ -231,17 +213,12 @@ fn test_booking_not_found() { let client = create_client(&env); client.init(&admin, &token, &oracle); - // Try to finalize non-existent booking let result = client.try_finalize_session(&999, &50); assert!(result.is_err()); } #[test] fn test_book_session_balance_transfer() { - // This test specifically verifies the acceptance criteria from Issue #6: - // - User's balance decreases - // - Contract's balance increases - // - Booking ID is unique and retrievable let env = Env::default(); env.mock_all_auths(); @@ -253,45 +230,34 @@ fn test_book_session_balance_transfer() { let token_admin = Address::generate(&env); let token = create_token_contract(&env, &token_admin); - // Initial balance let initial_balance = 5_000_i128; token.mint(&user, &initial_balance); let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session with specific deposit let rate_per_second = 5_i128; let max_duration = 200_u64; - let expected_deposit = rate_per_second * (max_duration as i128); // 1000 tokens + let expected_deposit = rate_per_second * (max_duration as i128); - // Verify initial state assert_eq!(token.balance(&user), initial_balance); assert_eq!(token.balance(&client.address), 0); - // Book session let booking_id = { client.set_my_rate(&expert, &rate_per_second); client.book_session(&user, &expert, &max_duration) }; - // Acceptance Criteria #1: User's balance decreases assert_eq!(token.balance(&user), initial_balance - expected_deposit); - - // Acceptance Criteria #2: Contract's balance increases assert_eq!(token.balance(&client.address), expected_deposit); - - // Acceptance Criteria #3: Booking ID is unique (first booking should be ID 1) assert_eq!(booking_id, 1); - // Create another booking to verify uniqueness - token.mint(&user, &expected_deposit); // Mint more tokens for second booking + token.mint(&user, &expected_deposit); let booking_id_2 = { client.set_my_rate(&expert, &rate_per_second); client.book_session(&user, &expert, &max_duration) }; - // Second booking should have different ID assert_eq!(booking_id_2, 2); assert_ne!(booking_id, booking_id_2); } @@ -314,7 +280,6 @@ fn test_get_user_and_expert_bookings() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create 2 bookings for the same user with different experts let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id_1 = { @@ -326,23 +291,28 @@ fn test_get_user_and_expert_bookings() { client.book_session(&user, &expert2, &max_duration) }; - // Test get_user_bookings - should return 2 bookings - let user_bookings = client.get_user_bookings(&user); + // Paginated: fetch all 2 user bookings starting at index 0 + let user_bookings = client.get_user_bookings(&user, &0, &10); assert_eq!(user_bookings.len(), 2); assert_eq!(user_bookings.get(0).unwrap(), booking_id_1); assert_eq!(user_bookings.get(1).unwrap(), booking_id_2); - // Test get_expert_bookings - expert1 should have 1 booking - let expert1_bookings = client.get_expert_bookings(&expert1); + // Count + assert_eq!(client.get_user_booking_count(&user), 2); + + // Expert1 has 1 booking + let expert1_bookings = client.get_expert_bookings(&expert1, &0, &10); assert_eq!(expert1_bookings.len(), 1); assert_eq!(expert1_bookings.get(0).unwrap(), booking_id_1); + assert_eq!(client.get_expert_booking_count(&expert1), 1); - // Test get_expert_bookings - expert2 should have 1 booking - let expert2_bookings = client.get_expert_bookings(&expert2); + // Expert2 has 1 booking + let expert2_bookings = client.get_expert_bookings(&expert2, &0, &10); assert_eq!(expert2_bookings.len(), 1); assert_eq!(expert2_bookings.get(0).unwrap(), booking_id_2); + assert_eq!(client.get_expert_booking_count(&expert2), 1); - // Test get_booking - verify we can retrieve booking details + // get_booking works let booking_1 = client.get_booking(&booking_id_1); assert!(booking_1.is_some()); let booking_1 = booking_1.unwrap(); @@ -351,7 +321,6 @@ fn test_get_user_and_expert_bookings() { assert_eq!(booking_1.expert, expert1); assert_eq!(booking_1.rate_per_second, rate_per_second); - // Test get_booking for non-existent booking let non_existent = client.get_booking(&999); assert!(non_existent.is_none()); } @@ -373,7 +342,6 @@ fn test_reclaim_stale_session_too_early() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -381,11 +349,9 @@ fn test_reclaim_stale_session_too_early() { client.book_session(&user, &expert, &max_duration) }; - // User tries to reclaim immediately (should fail - too early) let result = client.try_reclaim_stale_session(&user, &booking_id); assert!(result.is_err()); - // Verify funds are still in contract assert_eq!(token.balance(&client.address), 1_000); assert_eq!(token.balance(&user), 9_000); } @@ -407,7 +373,6 @@ fn test_reclaim_stale_session_success() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -415,15 +380,12 @@ fn test_reclaim_stale_session_success() { client.book_session(&user, &expert, &max_duration) }; - // Advance ledger timestamp by 25 hours (90000 seconds) env.ledger() .set_timestamp(env.ledger().timestamp() + 90_000); - // User tries to reclaim after 25 hours (should succeed) let result = client.try_reclaim_stale_session(&user, &booking_id); assert!(result.is_ok()); - // Verify funds returned to user assert_eq!(token.balance(&client.address), 0); assert_eq!(token.balance(&user), 10_000); assert_eq!(token.balance(&expert), 0); @@ -447,7 +409,6 @@ fn test_reclaim_stale_session_wrong_user() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -455,15 +416,12 @@ fn test_reclaim_stale_session_wrong_user() { client.book_session(&user, &expert, &max_duration) }; - // Advance ledger timestamp by 25 hours env.ledger() .set_timestamp(env.ledger().timestamp() + 90_000); - // Other user tries to reclaim (should fail - not authorized) let result = client.try_reclaim_stale_session(&other_user, &booking_id); assert!(result.is_err()); - // Verify funds still in contract assert_eq!(token.balance(&client.address), 1_000); } @@ -484,7 +442,6 @@ fn test_reclaim_already_finalized() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -492,14 +449,11 @@ fn test_reclaim_already_finalized() { client.book_session(&user, &expert, &max_duration) }; - // Oracle finalizes the session client.finalize_session(&booking_id, &50); - // Advance ledger timestamp by 25 hours env.ledger() .set_timestamp(env.ledger().timestamp() + 90_000); - // User tries to reclaim after finalization (should fail - not pending) let result = client.try_reclaim_stale_session(&user, &booking_id); assert!(result.is_err()); } @@ -521,7 +475,6 @@ fn test_expert_rejects_pending_session() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; let booking_id = { @@ -529,20 +482,16 @@ fn test_expert_rejects_pending_session() { client.book_session(&user, &expert, &max_duration) }; - // Verify initial state assert_eq!(token.balance(&user), 9_000); assert_eq!(token.balance(&client.address), 1_000); - // Expert rejects the session let result = client.try_reject_session(&expert, &booking_id); assert!(result.is_ok()); - // Verify user balance increased (full refund) assert_eq!(token.balance(&user), 10_000); assert_eq!(token.balance(&client.address), 0); assert_eq!(token.balance(&expert), 0); - // Verify booking status is Rejected let booking = client.get_booking(&booking_id).unwrap(); use crate::types::BookingStatus; assert_eq!(booking.status, BookingStatus::Rejected); @@ -572,11 +521,9 @@ fn test_user_cannot_reject_session() { client.book_session(&user, &expert, &max_duration) }; - // User tries to reject their own session (should fail - not authorized) let result = client.try_reject_session(&user, &booking_id); assert!(result.is_err()); - // Verify funds still in contract assert_eq!(token.balance(&client.address), 1_000); } @@ -604,10 +551,8 @@ fn test_reject_already_complete_session() { client.book_session(&user, &expert, &max_duration) }; - // Oracle finalizes the session client.finalize_session(&booking_id, &50); - // Expert tries to reject after completion (should fail - not pending) let result = client.try_reject_session(&expert, &booking_id); assert!(result.is_err()); } @@ -636,12 +581,10 @@ fn test_reject_already_reclaimed_session() { client.book_session(&user, &expert, &max_duration) }; - // Advance time and user reclaims env.ledger() .set_timestamp(env.ledger().timestamp() + 90_000); client.reclaim_stale_session(&user, &booking_id); - // Expert tries to reject after reclamation (should fail - not pending) let result = client.try_reject_session(&expert, &booking_id); assert!(result.is_err()); } @@ -671,11 +614,9 @@ fn test_wrong_expert_cannot_reject() { client.book_session(&user, &expert, &max_duration) }; - // Different expert tries to reject (should fail - not authorized) let result = client.try_reject_session(&wrong_expert, &booking_id); assert!(result.is_err()); - // Verify funds still in contract assert_eq!(token.balance(&client.address), 1_000); } @@ -692,7 +633,6 @@ fn test_reject_nonexistent_booking() { let client = create_client(&env); client.init(&admin, &token, &oracle); - // Expert tries to reject non-existent booking (should fail - not found) let result = client.try_reject_session(&expert, &999); assert!(result.is_err()); } @@ -712,15 +652,12 @@ fn test_expert_can_set_and_update_rate() { let client = create_client(&env); client.init(&admin, &token, &oracle); - // Initial set let res1 = client.try_set_my_rate(&expert, &10_i128); assert!(res1.is_ok()); - // Update rate let res2 = client.try_set_my_rate(&expert, &25_i128); assert!(res2.is_ok()); - // Fails with invalid rate let res3 = client.try_set_my_rate(&expert, &0_i128); assert!(res3.is_err()); } @@ -743,17 +680,14 @@ fn test_book_session_calculates_correct_deposit() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Set expert rate let stored_rate = 15_i128; client.set_my_rate(&expert, &stored_rate); - // Book session let max_duration = 100_u64; - let expected_deposit = stored_rate * (max_duration as i128); // 1500 tokens + let expected_deposit = stored_rate * (max_duration as i128); let _booking_id = client.book_session(&user, &expert, &max_duration); - // Verify correct deposit was extracted assert_eq!(token.balance(&user), initial_balance - expected_deposit); assert_eq!(token.balance(&client.address), expected_deposit); } @@ -775,9 +709,6 @@ fn test_book_session_fails_if_expert_rate_not_set() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Expert has NOT set rate - - // Book session should fail let max_duration = 100_u64; let res = client.try_book_session(&user, &expert, &max_duration); @@ -803,18 +734,14 @@ fn test_pause_blocks_book_session() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Set expert rate before pausing client.set_my_rate(&expert, &10_i128); - // Admin pauses the contract let result = client.try_pause(); assert!(result.is_ok()); - // User tries to book a session while paused (should fail) let result = client.try_book_session(&user, &expert, &100); assert!(result.is_err()); - // Verify user's balance is unchanged assert_eq!(token.balance(&user), 10_000); } @@ -835,16 +762,13 @@ fn test_pause_blocks_finalize_session() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session while unpaused let booking_id = { client.set_my_rate(&expert, &10_i128); client.book_session(&user, &expert, &100) }; - // Admin pauses the contract client.pause(); - // Oracle tries to finalize while paused (should fail) let result = client.try_finalize_session(&booking_id, &50); assert!(result.is_err()); } @@ -866,20 +790,16 @@ fn test_pause_blocks_reclaim_stale_session() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session while unpaused let booking_id = { client.set_my_rate(&expert, &10_i128); client.book_session(&user, &expert, &100) }; - // Advance time past reclaim timeout env.ledger() .set_timestamp(env.ledger().timestamp() + 90_000); - // Admin pauses the contract client.pause(); - // User tries to reclaim while paused (should fail) let result = client.try_reclaim_stale_session(&user, &booking_id); assert!(result.is_err()); } @@ -901,16 +821,13 @@ fn test_pause_blocks_reject_session() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book session while unpaused let booking_id = { client.set_my_rate(&expert, &10_i128); client.book_session(&user, &expert, &100) }; - // Admin pauses the contract client.pause(); - // Expert tries to reject while paused (should fail) let result = client.try_reject_session(&expert, &booking_id); assert!(result.is_err()); } @@ -932,21 +849,15 @@ fn test_unpause_resumes_operations() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Set expert rate client.set_my_rate(&expert, &10_i128); - - // Admin pauses the contract client.pause(); - // Booking should fail while paused let result = client.try_book_session(&user, &expert, &100); assert!(result.is_err()); - // Admin unpauses the contract let result = client.try_unpause(); assert!(result.is_ok()); - // Booking should succeed after unpause let booking_id = client.book_session(&user, &expert, &100); assert_eq!(booking_id, 1); assert_eq!(token.balance(&user), 9_000); @@ -970,23 +881,174 @@ fn test_read_only_functions_work_while_paused() { let client = create_client(&env); client.init(&admin, &token.address, &oracle); - // Book a session before pausing let booking_id = { client.set_my_rate(&expert, &10_i128); client.book_session(&user, &expert, &100) }; - // Admin pauses the contract client.pause(); - // Read-only functions should still work let booking = client.get_booking(&booking_id); assert!(booking.is_some()); assert_eq!(booking.unwrap().id, booking_id); - let user_bookings = client.get_user_bookings(&user); + // Paginated reads work while paused + let user_bookings = client.get_user_bookings(&user, &0, &10); assert_eq!(user_bookings.len(), 1); - let expert_bookings = client.get_expert_bookings(&expert); + let expert_bookings = client.get_expert_bookings(&expert, &0, &10); assert_eq!(expert_bookings.len(), 1); + + assert_eq!(client.get_user_booking_count(&user), 1); + assert_eq!(client.get_expert_booking_count(&expert), 1); +} + +// ==================== Scale & Pagination Tests ==================== + +/// Verifies that 50 bookings can be added to a single user without O(N) Vec growth. +/// Asserts count == 50, then uses pagination to fetch the first 10 and validates them. +#[test] +fn test_scale_50_bookings_single_user_with_pagination() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + // Mint enough tokens: rate=1, duration=1 per booking, 50 bookings = 50 tokens + token.mint(&user, &50_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + + // Expert sets rate + let rate_per_second = 1_i128; + let max_duration = 1_u64; // 1 token per booking to keep it cheap + client.set_my_rate(&expert, &rate_per_second); + + // Book 50 sessions + let mut booking_ids = std::vec::Vec::new(); + for _ in 0..50 { + let id = client.book_session(&user, &expert, &max_duration); + booking_ids.push(id); + } + + // Assert count is correct — O(1) counter, no Vec load + assert_eq!(client.get_user_booking_count(&user), 50); + assert_eq!(client.get_expert_booking_count(&expert), 50); + + // Fetch first page: start=0, limit=10 + let page1 = client.get_user_bookings(&user, &0, &10); + assert_eq!(page1.len(), 10); + + // Validate that each returned ID matches what was booked (IDs are 1-indexed globally) + for i in 0..10u32 { + let expected_id = booking_ids[i as usize]; + assert_eq!(page1.get(i).unwrap(), expected_id); + } + + // Fetch second page: start=10, limit=10 + let page2 = client.get_user_bookings(&user, &10, &10); + assert_eq!(page2.len(), 10); + for i in 0..10u32 { + let expected_id = booking_ids[(10 + i) as usize]; + assert_eq!(page2.get(i).unwrap(), expected_id); + } + + // Fetch last page: start=45, limit=10 → should return 5 items + let last_page = client.get_user_bookings(&user, &45, &10); + assert_eq!(last_page.len(), 5); + + // Fetch out-of-range page: start=50, limit=10 → should return 0 items + let empty_page = client.get_user_bookings(&user, &50, &10); + assert_eq!(empty_page.len(), 0); +} + +/// Verifies pagination is independent per user — two users with 25 bookings each +/// don't interfere with each other's indices. +#[test] +fn test_pagination_isolation_between_users() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user_a, &25_000); + token.mint(&user_b, &25_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + + client.set_my_rate(&expert, &1_i128); + + // 25 bookings for user_a then 25 for user_b (interleaved global booking IDs) + for _ in 0..25 { + client.book_session(&user_a, &expert, &1); + client.book_session(&user_b, &expert, &1); + } + + assert_eq!(client.get_user_booking_count(&user_a), 25); + assert_eq!(client.get_user_booking_count(&user_b), 25); + + // Each user's first page should be 10 items, distinct from the other + let page_a = client.get_user_bookings(&user_a, &0, &10); + let page_b = client.get_user_bookings(&user_b, &0, &10); + + assert_eq!(page_a.len(), 10); + assert_eq!(page_b.len(), 10); + + // user_a gets odd global IDs (1,3,5,...), user_b gets even (2,4,6,...) + // Just assert they don't overlap + for i in 0..10u32 { + let id_a = page_a.get(i).unwrap(); + let id_b = page_b.get(i).unwrap(); + assert_ne!( + id_a, id_b, + "user_a and user_b share a booking_id — isolation broken" + ); + } +} + +/// Verifies expert pagination works correctly for 50 sessions. +#[test] +fn test_expert_pagination_50_bookings() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let expert = Address::generate(&env); + let oracle = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle); + client.set_my_rate(&expert, &1_i128); + + // 50 different users each book 1 session with the same expert + for _ in 0..50 { + let user = Address::generate(&env); + token.mint(&user, &1); + client.book_session(&user, &expert, &1); + } + + assert_eq!(client.get_expert_booking_count(&expert), 50); + + let page = client.get_expert_bookings(&expert, &0, &10); + assert_eq!(page.len(), 10); + + let tail = client.get_expert_bookings(&expert, &40, &20); + assert_eq!(tail.len(), 10); // only 10 left from index 40 to 49 }