Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions contracts/payment-vault-contract/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,63 @@ 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 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. Update booking status to DisputedAndResolved
storage::update_booking_status(env, booking_id, BookingStatus::DisputedAndResolved);

// 8. Emit event
events::dispute_resolved(env, booking_id, user_refund, expert_pay);

Ok(())
}
9 changes: 8 additions & 1 deletion contracts/payment-vault-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,12 @@ 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));
}
12 changes: 12 additions & 0 deletions contracts/payment-vault-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@ 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)
}

/// 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<u64> {
Expand Down
237 changes: 237 additions & 0 deletions contracts/payment-vault-contract/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![cfg(test)]
use crate::types::BookingStatus;
use crate::{PaymentVaultContract, PaymentVaultContractClient};
use soroban_sdk::{
testutils::{Address as _, Ledger},
Expand Down Expand Up @@ -1480,3 +1481,239 @@ 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, &registry);

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, &registry);

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, &registry);

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, &registry);

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_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, &registry);

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, &registry);

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.
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_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, &registry);

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, &registry);

let result = client.try_resolve_dispute(&999, &100, &100);
assert!(result.is_err());
}
19 changes: 10 additions & 9 deletions contracts/payment-vault-contract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@ pub enum BookingStatus {
Complete = 1,
Rejected = 2,
Reclaimed = 3,
DisputedAndResolved = 4,
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 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<u64>, // 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<u64>, // Ledger timestamp when Oracle marked the session active; None means not yet started
}