diff --git a/contracts/payment-vault-contract/src/contract.rs b/contracts/payment-vault-contract/src/contract.rs index 3b4196f..f75c108 100644 --- a/contracts/payment-vault-contract/src/contract.rs +++ b/contracts/payment-vault-contract/src/contract.rs @@ -119,6 +119,9 @@ pub fn book_session( status: BookingStatus::Pending, created_at: env.ledger().timestamp(), started_at: None, + dispute_user_refund: None, + dispute_expert_pay: None, + dispute_remainder_recovered: false, }; // Save booking @@ -411,3 +414,115 @@ pub fn reject_session(env: &Env, expert: &Address, booking_id: u64) -> Result<() Ok(()) } + +/// Admin dispute resolution: forcefully split escrowed funds for a Pending booking. +/// Used when the Oracle crashes or an unresolvable dispute occurs between user and expert. +pub fn resolve_dispute( + env: &Env, + booking_id: u64, + user_refund: i128, + expert_pay: i128, +) -> Result<(), VaultError> { + if storage::is_paused(env) { + return Err(VaultError::ContractPaused); + } + + // 1. Require admin authorization + let admin = storage::get_admin(env).ok_or(VaultError::NotInitialized)?; + admin.require_auth(); + + // 2. Get booking and verify it exists + let mut booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; + + // 3. Verify booking is in Pending status + if booking.status != BookingStatus::Pending { + return Err(VaultError::BookingNotPending); + } + + // 4. Validate split amounts + if user_refund < 0 || expert_pay < 0 { + return Err(VaultError::InvalidAmount); + } + + let total_split = user_refund + .checked_add(expert_pay) + .ok_or(VaultError::Overflow)?; + + if total_split > booking.total_deposit { + return Err(VaultError::InvalidAmount); + } + + // 5. Get token contract + let token_address = storage::get_token(env); + let token_client = token::Client::new(env, &token_address); + let contract_address = env.current_contract_address(); + + // 6. Execute transfers + if user_refund > 0 { + token_client.transfer(&contract_address, &booking.user, &user_refund); + } + + if expert_pay > 0 { + token_client.transfer(&contract_address, &booking.expert, &expert_pay); + } + + // 7. Persist dispute split and transition booking to DisputedAndResolved + booking.status = BookingStatus::DisputedAndResolved; + booking.dispute_user_refund = Some(user_refund); + booking.dispute_expert_pay = Some(expert_pay); + booking.dispute_remainder_recovered = false; + storage::update_booking(env, &booking); + + // 8. Emit event + events::dispute_resolved(env, booking_id, user_refund, expert_pay); + + Ok(()) +} + +/// Admin-only recovery path for disputed remainder left in vault after resolve_dispute. +/// Recovers `total_deposit - dispute_user_refund - dispute_expert_pay` exactly once. +pub fn recover_disputed_remainder(env: &Env, booking_id: u64) -> Result { + if storage::is_paused(env) { + return Err(VaultError::ContractPaused); + } + + let admin = storage::get_admin(env).ok_or(VaultError::NotInitialized)?; + admin.require_auth(); + + let mut booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; + + if booking.status != BookingStatus::DisputedAndResolved { + return Err(VaultError::BookingNotDisputed); + } + + if booking.dispute_remainder_recovered { + return Err(VaultError::RemainderAlreadyRecovered); + } + + let user_refund = booking.dispute_user_refund.unwrap_or(0); + let expert_pay = booking.dispute_expert_pay.unwrap_or(0); + + let remainder = booking + .total_deposit + .checked_sub(user_refund) + .and_then(|v| v.checked_sub(expert_pay)) + .ok_or(VaultError::Overflow)?; + + if remainder < 0 { + return Err(VaultError::InvalidAmount); + } + + let token_address = storage::get_token(env); + let token_client = token::Client::new(env, &token_address); + let contract_address = env.current_contract_address(); + + if remainder > 0 { + token_client.transfer(&contract_address, &admin, &remainder); + } + + booking.dispute_remainder_recovered = true; + storage::update_booking(env, &booking); + events::disputed_remainder_recovered(env, booking_id, remainder); + + Ok(remainder) +} diff --git a/contracts/payment-vault-contract/src/error.rs b/contracts/payment-vault-contract/src/error.rs index 92ed6f1..7fe923f 100644 --- a/contracts/payment-vault-contract/src/error.rs +++ b/contracts/payment-vault-contract/src/error.rs @@ -16,4 +16,6 @@ pub enum VaultError { ExpertNotVerified = 10, SessionAlreadyStarted = 11, Overflow = 12, + BookingNotDisputed = 13, + RemainderAlreadyRecovered = 14, } diff --git a/contracts/payment-vault-contract/src/events.rs b/contracts/payment-vault-contract/src/events.rs index e93538a..f249e76 100644 --- a/contracts/payment-vault-contract/src/events.rs +++ b/contracts/payment-vault-contract/src/events.rs @@ -72,5 +72,18 @@ pub fn oracle_updated(env: &Env, old_oracle: &Address, new_oracle: &Address) { /// Emitted when a user tops up a pending session pub fn session_topped_up(env: &Env, booking_id: u64, additional_duration: u64, extra_cost: i128) { let topics = (symbol_short!("top_up"), booking_id); - env.events().publish(topics, (additional_duration, extra_cost)); + env.events() + .publish(topics, (additional_duration, extra_cost)); +} + +/// Emitted when an admin resolves a dispute by splitting escrowed funds +pub fn dispute_resolved(env: &Env, booking_id: u64, user_refund: i128, expert_pay: i128) { + let topics = (symbol_short!("dispute"), booking_id); + env.events().publish(topics, (user_refund, expert_pay)); +} + +/// Emitted when admin recovers remaining disputed funds left in vault +pub fn disputed_remainder_recovered(env: &Env, booking_id: u64, amount: i128) { + let topics = (symbol_short!("dsp_rcvr"), booking_id); + env.events().publish(topics, amount); } diff --git a/contracts/payment-vault-contract/src/lib.rs b/contracts/payment-vault-contract/src/lib.rs index 76d10e2..8e95ddc 100644 --- a/contracts/payment-vault-contract/src/lib.rs +++ b/contracts/payment-vault-contract/src/lib.rs @@ -122,6 +122,24 @@ impl PaymentVaultContract { contract::cancel_booking(&env, &user, booking_id) } + /// Resolve a dispute by forcefully splitting escrowed funds (Admin-only). + /// Used when the Oracle crashes or a severe, unresolvable dispute occurs. + /// `user_refund + expert_pay` must not exceed the booking's `total_deposit`. + pub fn resolve_dispute( + env: Env, + booking_id: u64, + user_refund: i128, + expert_pay: i128, + ) -> Result<(), VaultError> { + contract::resolve_dispute(&env, booking_id, user_refund, expert_pay) + } + + /// Recover any disputed remainder still locked in vault after dispute split (Admin-only). + /// Can be executed once per booking after status reaches DisputedAndResolved. + pub fn recover_disputed_remainder(env: Env, booking_id: u64) -> Result { + contract::recover_disputed_remainder(&env, 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 de7fbd0..abe8307 100644 --- a/contracts/payment-vault-contract/src/storage.rs +++ b/contracts/payment-vault-contract/src/storage.rs @@ -110,6 +110,10 @@ pub fn update_booking_status(env: &Env, booking_id: u64, status: BookingStatus) } } +pub fn update_booking(env: &Env, booking: &BookingRecord) { + save_booking(env, booking); +} + 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); diff --git a/contracts/payment-vault-contract/src/test.rs b/contracts/payment-vault-contract/src/test.rs index b9fa490..82d5132 100644 --- a/contracts/payment-vault-contract/src/test.rs +++ b/contracts/payment-vault-contract/src/test.rs @@ -1,4 +1,5 @@ #![cfg(test)] +use crate::types::BookingStatus; use crate::{PaymentVaultContract, PaymentVaultContractClient}; use soroban_sdk::{ testutils::{Address as _, Ledger}, @@ -1480,3 +1481,379 @@ fn test_top_up_wrong_user_fails() { let result = client.try_top_up_session(&other_user, &booking_id, &900); assert!(result.is_err()); } + +// ==================== Dispute Resolution Tests ==================== + +#[test] +fn test_resolve_dispute_admin_splits_funds() { + 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) + }; + + // Deposit is 1000. Split: 600 to user, 400 to expert. + client.resolve_dispute(&booking_id, &600, &400); + + assert_eq!(token.balance(&user), 9_600); + assert_eq!(token.balance(&expert), 400); + assert_eq!(token.balance(&client.address), 0); + + let booking = client.get_booking(&booking_id).unwrap(); + assert_eq!(booking.status, BookingStatus::DisputedAndResolved); +} + +#[test] +fn test_resolve_dispute_only_admin_can_call() { + 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) + }; + + // Clear all mocked auths — now calls requiring auth will fail + env.set_auths(&[]); + + let result = client.try_resolve_dispute(&booking_id, &500, &500); + assert!(result.is_err()); +} + +#[test] +#[should_panic] +fn test_resolve_dispute_split_exceeds_deposit_panics() { + 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) + }; + + // Deposit is 1000. Split of 600 + 500 = 1100 exceeds deposit. + client.resolve_dispute(&booking_id, &600, &500); +} + +#[test] +fn test_resolve_dispute_split_exceeds_deposit_returns_error() { + 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) + }; + + // Deposit is 1000. Split of 600 + 500 = 1100 exceeds deposit. + let result = client.try_resolve_dispute(&booking_id, &600, &500); + assert!(result.is_err()); + + // Balances unchanged + assert_eq!(token.balance(&user), 9_000); + assert_eq!(token.balance(&client.address), 1_000); +} + +#[test] +fn test_resolve_dispute_negative_split_amounts_return_error() { + 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) + }; + + let negative_user_refund = client.try_resolve_dispute(&booking_id, &-1, &500); + assert!(negative_user_refund.is_err()); + + let negative_expert_pay = client.try_resolve_dispute(&booking_id, &500, &-1); + assert!(negative_expert_pay.is_err()); + + // Booking should remain pending and balances unchanged after invalid inputs. + let booking = client.get_booking(&booking_id).unwrap(); + assert_eq!(booking.status, BookingStatus::Pending); + assert_eq!(token.balance(&user), 9_000); + assert_eq!(token.balance(&expert), 0); + assert_eq!(token.balance(&client.address), 1_000); +} + +#[test] +fn test_resolve_dispute_only_pending_bookings() { + 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) + }; + + // Finalize first — booking is now Complete + client.finalize_session(&booking_id, &50); + + // Attempt dispute resolution on a completed booking + let result = client.try_resolve_dispute(&booking_id, &250, &250); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_dispute_partial_split_leaves_remainder_in_vault() { + 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) + }; + + // Deposit is 1000. Split only 300 + 200 = 500. Remaining 500 stays in vault + // until admin explicitly recovers it. + client.resolve_dispute(&booking_id, &300, &200); + + assert_eq!(token.balance(&user), 9_300); + assert_eq!(token.balance(&expert), 200); + assert_eq!(token.balance(&client.address), 500); +} + +#[test] +fn test_admin_can_recover_disputed_remainder_once() { + 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) + }; + + // Deposit is 1000; 500 is intentionally left to recover. + client.resolve_dispute(&booking_id, &300, &200); + assert_eq!(token.balance(&client.address), 500); + + let recovered = client.recover_disputed_remainder(&booking_id); + assert_eq!(recovered, 500); + assert_eq!(token.balance(&admin), 500); + assert_eq!(token.balance(&client.address), 0); + + let booking = client.get_booking(&booking_id).unwrap(); + assert!(booking.dispute_remainder_recovered); + + // Double recovery is blocked. + let second = client.try_recover_disputed_remainder(&booking_id); + assert!(second.is_err()); +} + +#[test] +fn test_non_admin_cannot_recover_disputed_remainder() { + 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) + }; + + client.resolve_dispute(&booking_id, &300, &200); + + // Disable mocked auths so admin auth is no longer auto-satisfied. + env.set_auths(&[]); + let result = client.try_recover_disputed_remainder(&booking_id); + assert!(result.is_err()); + + // Funds remain in vault because recovery auth failed. + assert_eq!(token.balance(&client.address), 500); +} + +#[test] +fn test_recover_disputed_remainder_requires_disputed_status() { + 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) + }; + + // Booking is still pending, so recovery must fail. + let result = client.try_recover_disputed_remainder(&booking_id); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_dispute_blocked_when_paused() { + 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) + }; + + client.pause(); + + let result = client.try_resolve_dispute(&booking_id, &500, &500); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_dispute_nonexistent_booking() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = 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); + + let client = create_client(&env); + client.init(&admin, &token.address, &oracle, ®istry); + + let result = client.try_resolve_dispute(&999, &100, &100); + assert!(result.is_err()); +} diff --git a/contracts/payment-vault-contract/src/types.rs b/contracts/payment-vault-contract/src/types.rs index d097fa5..aa7257c 100644 --- a/contracts/payment-vault-contract/src/types.rs +++ b/contracts/payment-vault-contract/src/types.rs @@ -9,6 +9,7 @@ pub enum BookingStatus { Complete = 1, Rejected = 2, Reclaimed = 3, + DisputedAndResolved = 4, Cancelled = 5, } @@ -16,13 +17,16 @@ pub enum BookingStatus { #[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 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 + 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 + pub dispute_user_refund: Option, // Admin-applied refund in dispute resolution, if resolved by dispute flow + pub dispute_expert_pay: Option, // Admin-applied expert payout in dispute resolution, if resolved by dispute flow + pub dispute_remainder_recovered: bool, // True once admin has recovered disputed remainder for this booking }