Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
75 changes: 71 additions & 4 deletions contracts/payment-vault-contract/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -114,6 +118,7 @@ pub fn book_session(
total_deposit,
status: BookingStatus::Pending,
created_at: env.ledger().timestamp(),
started_at: None,
};

// Save booking
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment on lines +252 to +260
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

A started booking still remains on the pre-start lifecycle path.

This only stamps started_at; it leaves the record Pending. reclaim_stale_session and reject_session still accept Pending bookings, so a started session can still be fully unwound later, and repeated calls here can overwrite the original start timestamp. Either move the booking out of Pending here or gate those paths on started_at.is_none().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/payment-vault-contract/src/contract.rs` around lines 252 - 260, The
booking is stamped with started_at but left in BookingStatus::Pending, which
allows reclaim_stale_session and reject_session to operate on started sessions
and permits overwriting started_at; update the code in this path (around
storage::get_booking, storage::update_booking_started_at,
events::session_started) to also transition the booking status out of Pending
(e.g., set to a Started/Active enum variant) via the appropriate storage update,
and emit any corresponding event, or alternatively modify reclaim_stale_session
and reject_session to first check booking.started_at.is_none() before acting;
pick one: either change this flow to call storage::update_booking_status(...) to
mark the booking started when stamping started_at, or add started_at.is_none()
guards in reclaim_stale_session/reject_session.


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();
Expand Down
2 changes: 2 additions & 0 deletions contracts/payment-vault-contract/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ pub enum VaultError {
ContractPaused = 8,
ExpertRateNotSet = 9,
ExpertNotVerified = 10,
SessionAlreadyStarted = 11,
Overflow = 12,
}
12 changes: 12 additions & 0 deletions contracts/payment-vault-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
20 changes: 19 additions & 1 deletion contracts/payment-vault-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<u64> {
Expand Down
7 changes: 7 additions & 0 deletions contracts/payment-vault-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
121 changes: 121 additions & 0 deletions contracts/payment-vault-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &registry);

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

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

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() {
Expand Down
18 changes: 10 additions & 8 deletions contracts/payment-vault-contract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>, // Ledger timestamp when Oracle marked the session active; None means not yet started
}
Loading