diff --git a/contracts/payment-vault-contract/src/contract.rs b/contracts/payment-vault-contract/src/contract.rs index b4a3187..e9b10ac 100644 --- a/contracts/payment-vault-contract/src/contract.rs +++ b/contracts/payment-vault-contract/src/contract.rs @@ -88,8 +88,12 @@ pub fn book_session( return Err(VaultError::InvalidAmount); } - // Calculate total deposit - let total_deposit = rate_per_second * (max_duration as i128); + // Calculate total deposit. + // rate_per_second must be expressed in atomic units of the payment token + // (e.g., stroops for XLM with 7 decimals, or 10^18 base units for 18-decimal tokens). + let total_deposit = rate_per_second + .checked_mul(max_duration as i128) + .ok_or(VaultError::Overflow)?; if total_deposit <= 0 { return Err(VaultError::InvalidAmount); @@ -114,6 +118,7 @@ pub fn book_session( total_deposit, status: BookingStatus::Pending, created_at: env.ledger().timestamp(), + started_at: None, }; // Save booking @@ -150,8 +155,13 @@ pub fn finalize_session( return Err(VaultError::BookingNotPending); } - // 4. Calculate payments - let expert_pay = booking.rate_per_second * (actual_duration as i128); + // 4. Calculate payments. + // rate_per_second is stored in atomic units of the payment token, so this + // multiplication is safe for any token precision as long as the product fits i128. + let expert_pay = booking + .rate_per_second + .checked_mul(actual_duration as i128) + .ok_or(VaultError::Overflow)?; let refund = booking.total_deposit - expert_pay; // Ensure calculations are valid @@ -229,6 +239,63 @@ pub fn reclaim_stale_session(env: &Env, user: &Address, booking_id: u64) -> Resu Ok(()) } +/// Mark a session as started (Oracle-only). +/// Once started, the user can no longer cancel the booking. +pub fn mark_session_started(env: &Env, booking_id: u64) -> Result<(), VaultError> { + if storage::is_paused(env) { + return Err(VaultError::ContractPaused); + } + + let oracle = storage::get_oracle(env); + oracle.require_auth(); + + let booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; + + if booking.status != BookingStatus::Pending { + return Err(VaultError::BookingNotPending); + } + + let started_at = env.ledger().timestamp(); + storage::update_booking_started_at(env, booking_id, started_at); + events::session_started(env, booking_id, started_at); + + Ok(()) +} + +/// Cancel a pending booking and receive a full refund (User-only). +/// Can only be cancelled if the Oracle has not yet marked it as started. +pub fn cancel_booking(env: &Env, user: &Address, booking_id: u64) -> Result<(), VaultError> { + if storage::is_paused(env) { + return Err(VaultError::ContractPaused); + } + + user.require_auth(); + + let booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; + + if booking.user != *user { + return Err(VaultError::NotAuthorized); + } + + if booking.status != BookingStatus::Pending { + return Err(VaultError::BookingNotPending); + } + + if booking.started_at.is_some() { + return Err(VaultError::SessionAlreadyStarted); + } + + let token_address = storage::get_token(env); + let token_client = token::Client::new(env, &token_address); + let contract_address = env.current_contract_address(); + token_client.transfer(&contract_address, &booking.user, &booking.total_deposit); + + storage::update_booking_status(env, booking_id, BookingStatus::Cancelled); + events::booking_cancelled(env, booking_id, booking.total_deposit); + + Ok(()) +} + pub fn transfer_admin(env: &Env, new_admin: &Address) -> Result<(), VaultError> { let current_admin = storage::get_admin(env).ok_or(VaultError::NotInitialized)?; current_admin.require_auth(); diff --git a/contracts/payment-vault-contract/src/error.rs b/contracts/payment-vault-contract/src/error.rs index ce3ef13..92ed6f1 100644 --- a/contracts/payment-vault-contract/src/error.rs +++ b/contracts/payment-vault-contract/src/error.rs @@ -14,4 +14,6 @@ pub enum VaultError { ContractPaused = 8, ExpertRateNotSet = 9, ExpertNotVerified = 10, + SessionAlreadyStarted = 11, + Overflow = 12, } diff --git a/contracts/payment-vault-contract/src/events.rs b/contracts/payment-vault-contract/src/events.rs index f72da0e..529b0a5 100644 --- a/contracts/payment-vault-contract/src/events.rs +++ b/contracts/payment-vault-contract/src/events.rs @@ -37,6 +37,18 @@ pub fn session_rejected(env: &Env, booking_id: u64, reason: &str) { env.events().publish(topics, reason); } +/// Emitted when a user cancels a pending booking and receives a full refund +pub fn booking_cancelled(env: &Env, booking_id: u64, amount: i128) { + let topics = (symbol_short!("cancel"), booking_id); + env.events().publish(topics, amount); +} + +/// Emitted when the Oracle marks a session as active/started +pub fn session_started(env: &Env, booking_id: u64, started_at: u64) { + let topics = (symbol_short!("started"), booking_id); + env.events().publish(topics, started_at); +} + /// Emitted when an expert updates their rate pub fn expert_rate_updated(env: &Env, expert: &Address, rate: i128) { let topics = (symbol_short!("rate_upd"), expert.clone()); diff --git a/contracts/payment-vault-contract/src/lib.rs b/contracts/payment-vault-contract/src/lib.rs index 3826f2f..e2f31b9 100644 --- a/contracts/payment-vault-contract/src/lib.rs +++ b/contracts/payment-vault-contract/src/lib.rs @@ -51,13 +51,17 @@ impl PaymentVaultContract { contract::set_oracle(&env, &new_oracle) } - /// Set an expert's own rate per second + /// Set an expert's own rate per second. + /// `rate_per_second` MUST be expressed in atomic units of the payment token + /// (e.g., 1 XLM = 10_000_000 stroops; 1 18-decimal token = 10^18 base units). pub fn set_my_rate(env: Env, expert: Address, rate_per_second: i128) -> Result<(), VaultError> { 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. + /// Both `rate_per_second` and the resulting `total_deposit` are denominated in + /// atomic units of the configured payment token to correctly handle any token precision. pub fn book_session( env: Env, user: Address, @@ -93,6 +97,20 @@ impl PaymentVaultContract { contract::reject_session(&env, &expert, booking_id) } + /// Mark a session as started (Oracle-only). + /// Once called, the user can no longer cancel the booking. + pub fn mark_session_started(env: Env, booking_id: u64) -> Result<(), VaultError> { + contract::mark_session_started(&env, booking_id) + } + + /// Cancel a pending booking and receive a full refund (User-only). + /// Cancellation is only allowed if the Oracle has not yet marked the session as started. + /// `rate_per_second` and `total_deposit` must always be expressed in atomic units + /// of the payment token (e.g., stroops for XLM, or 10^18 base units for 18-decimal tokens). + pub fn cancel_booking(env: Env, user: Address, booking_id: u64) -> Result<(), VaultError> { + contract::cancel_booking(&env, &user, booking_id) + } + /// 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 { diff --git a/contracts/payment-vault-contract/src/storage.rs b/contracts/payment-vault-contract/src/storage.rs index 5a8bc47..de7fbd0 100644 --- a/contracts/payment-vault-contract/src/storage.rs +++ b/contracts/payment-vault-contract/src/storage.rs @@ -110,6 +110,13 @@ pub fn update_booking_status(env: &Env, booking_id: u64, status: BookingStatus) } } +pub fn update_booking_started_at(env: &Env, booking_id: u64, started_at: u64) { + if let Some(mut booking) = get_booking(env, booking_id) { + booking.started_at = Some(started_at); + save_booking(env, &booking); + } +} + // --- User Booking List (O(1) indexed storage) --- /// Returns how many bookings a user has booked in total. diff --git a/contracts/payment-vault-contract/src/test.rs b/contracts/payment-vault-contract/src/test.rs index a3581e8..67337f3 100644 --- a/contracts/payment-vault-contract/src/test.rs +++ b/contracts/payment-vault-contract/src/test.rs @@ -1249,6 +1249,127 @@ fn test_pagination_isolation_between_users() { } } +// ==================== Booking Cancellation Tests (Issue #36) ==================== + +#[test] +fn test_user_cancels_before_session_starts_success() { + 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 registry = create_mock_registry(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user, &10_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle, ®istry); + + let booking_id = { + client.set_my_rate(&expert, &10_i128); + client.book_session(&user, &expert, &100) + }; + + assert_eq!(token.balance(&user), 9_000); + assert_eq!(token.balance(&client.address), 1_000); + + // User cancels immediately — session not yet started + let result = client.try_cancel_booking(&user, &booking_id); + assert!(result.is_ok()); + + // Full refund returned to user + assert_eq!(token.balance(&user), 10_000); + assert_eq!(token.balance(&client.address), 0); + + let booking = client.get_booking(&booking_id).unwrap(); + use crate::types::BookingStatus; + assert_eq!(booking.status, BookingStatus::Cancelled); +} + +#[test] +fn test_user_cannot_cancel_after_oracle_marks_started() { + 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 registry = create_mock_registry(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + token.mint(&user, &10_000); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle, ®istry); + + let booking_id = { + client.set_my_rate(&expert, &10_i128); + client.book_session(&user, &expert, &100) + }; + + // Oracle marks session as started + let result = client.try_mark_session_started(&booking_id); + assert!(result.is_ok()); + + // User tries to cancel — should fail because session has started + let result = client.try_cancel_booking(&user, &booking_id); + assert!(result.is_err()); + + // Funds remain locked in contract + assert_eq!(token.balance(&client.address), 1_000); + assert_eq!(token.balance(&user), 9_000); +} + +// ==================== Dynamic Precision Tests (Issue #38) ==================== + +#[test] +fn test_booking_with_18_decimal_token_scale_no_overflow() { + 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 registry = create_mock_registry(&env); + + let token_admin = Address::generate(&env); + let token = create_token_contract(&env, &token_admin); + + // Simulate a token with 18 decimals: + // 1 token/second = 1_000_000_000_000_000_000 atomic units/second + let rate_per_second: i128 = 1_000_000_000_000_000_000_i128; // 10^18 + let max_duration: u64 = 100; // 100 seconds + let expected_deposit: i128 = rate_per_second * max_duration as i128; // 10^20 — well within i128 + + // Mint enough tokens to cover deposit + token.mint(&user, &expected_deposit); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle, ®istry); + + client.set_my_rate(&expert, &rate_per_second); + let booking_id = client.book_session(&user, &expert, &max_duration); + + assert_eq!(token.balance(&user), 0); + assert_eq!(token.balance(&client.address), expected_deposit); + + // Finalize for 50 seconds + client.finalize_session(&booking_id, &50); + + let expert_pay = rate_per_second * 50_i128; + let refund = expected_deposit - expert_pay; + assert_eq!(token.balance(&expert), expert_pay); + assert_eq!(token.balance(&user), refund); + assert_eq!(token.balance(&client.address), 0); +} + /// Verifies expert pagination works correctly for 50 sessions. #[test] fn test_expert_pagination_50_bookings() { diff --git a/contracts/payment-vault-contract/src/types.rs b/contracts/payment-vault-contract/src/types.rs index 6b5e4c5..d097fa5 100644 --- a/contracts/payment-vault-contract/src/types.rs +++ b/contracts/payment-vault-contract/src/types.rs @@ -9,18 +9,20 @@ pub enum BookingStatus { Complete = 1, Rejected = 2, Reclaimed = 3, + Cancelled = 5, } /// Record of a consultation booking with deposit locked #[contracttype] #[derive(Clone, Debug)] pub struct BookingRecord { - pub id: u64, // Storage key identifier - pub user: Address, // User who created the booking - pub expert: Address, // Expert providing consultation - pub rate_per_second: i128, // Payment rate per second - pub max_duration: u64, // Maximum booked duration in seconds - pub total_deposit: i128, // Total deposit (rate_per_second * max_duration) - pub status: BookingStatus, // Current booking status - pub created_at: u64, // Ledger timestamp when booking was created + pub id: u64, // Storage key identifier + pub user: Address, // User who created the booking + pub expert: Address, // Expert providing consultation + pub rate_per_second: i128, // Payment rate per second in atomic units of the payment token + pub max_duration: u64, // Maximum booked duration in seconds + pub total_deposit: i128, // Total deposit (rate_per_second * max_duration) + pub status: BookingStatus, // Current booking status + pub created_at: u64, // Ledger timestamp when booking was created + pub started_at: Option, // Ledger timestamp when Oracle marked the session active; None means not yet started }