From 2773d6bba5e3aaec678b92c861d90edab0a617ab Mon Sep 17 00:00:00 2001 From: dinahmaccodes Date: Wed, 25 Feb 2026 01:45:09 +0100 Subject: [PATCH 1/2] feat: add expert rate support to payment vault --- .../payment-vault-contract/src/contract.rs | 49 ++++++++++--------- contracts/payment-vault-contract/src/error.rs | 3 +- .../payment-vault-contract/src/events.rs | 20 ++++++-- contracts/payment-vault-contract/src/lib.rs | 20 ++++---- .../payment-vault-contract/src/storage.rs | 26 ++++++++-- 5 files changed, 77 insertions(+), 41 deletions(-) diff --git a/contracts/payment-vault-contract/src/contract.rs b/contracts/payment-vault-contract/src/contract.rs index 0673145..64f343a 100644 --- a/contracts/payment-vault-contract/src/contract.rs +++ b/contracts/payment-vault-contract/src/contract.rs @@ -1,14 +1,14 @@ -use soroban_sdk::{Address, Env, token}; -use crate::storage; -use crate::types::{BookingRecord, BookingStatus}; use crate::error::VaultError; use crate::events; +use crate::storage; +use crate::types::{BookingRecord, BookingStatus}; +use soroban_sdk::{token, Address, Env}; pub fn initialize_vault( env: &Env, admin: &Address, token: &Address, - oracle: &Address + oracle: &Address, ) -> Result<(), VaultError> { // 1. Check if already initialized if storage::has_admin(env) { @@ -23,16 +23,32 @@ pub fn initialize_vault( Ok(()) } +pub fn set_my_rate(env: &Env, expert: &Address, rate_per_second: i128) -> Result<(), VaultError> { + expert.require_auth(); + + if rate_per_second <= 0 { + return Err(VaultError::InvalidAmount); + } + + storage::set_expert_rate(env, expert, rate_per_second); + events::expert_rate_updated(env, expert, rate_per_second); + + Ok(()) +} + pub fn book_session( env: &Env, user: &Address, expert: &Address, - rate_per_second: i128, max_duration: u64, ) -> Result { // Require authorization from the user creating the booking user.require_auth(); + // Fetch the expert's rate + let rate_per_second = + storage::get_expert_rate(env, expert).ok_or(VaultError::ExpertRateNotSet)?; + // Validate rate if rate_per_second <= 0 { return Err(VaultError::InvalidAmount); @@ -89,8 +105,7 @@ pub fn finalize_session( oracle.require_auth(); // 2. Get booking and verify it exists - let booking = storage::get_booking(env, booking_id) - .ok_or(VaultError::BookingNotFound)?; + let booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; // 3. Verify booking is in Pending status if booking.status != BookingStatus::Pending { @@ -134,17 +149,12 @@ pub fn finalize_session( /// 24 hours in seconds const RECLAIM_TIMEOUT: u64 = 86400; -pub fn reclaim_stale_session( - env: &Env, - user: &Address, - booking_id: u64, -) -> Result<(), VaultError> { +pub fn reclaim_stale_session(env: &Env, user: &Address, booking_id: u64) -> Result<(), VaultError> { // 1. Require user authorization user.require_auth(); // 2. Get booking and verify it exists - let booking = storage::get_booking(env, booking_id) - .ok_or(VaultError::BookingNotFound)?; + let booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; // 3. Verify the caller is the booking owner if booking.user != *user { @@ -177,17 +187,12 @@ pub fn reclaim_stale_session( Ok(()) } -pub fn reject_session( - env: &Env, - expert: &Address, - booking_id: u64, -) -> Result<(), VaultError> { +pub fn reject_session(env: &Env, expert: &Address, booking_id: u64) -> Result<(), VaultError> { // 1. Require expert authorization expert.require_auth(); // 2. Get booking and verify it exists - let booking = storage::get_booking(env, booking_id) - .ok_or(VaultError::BookingNotFound)?; + let booking = storage::get_booking(env, booking_id).ok_or(VaultError::BookingNotFound)?; // 3. Verify the caller is the expert in the booking if booking.expert != *expert { @@ -212,4 +217,4 @@ pub fn reject_session( events::session_rejected(env, booking_id, "Expert declined session"); Ok(()) -} \ No newline at end of file +} diff --git a/contracts/payment-vault-contract/src/error.rs b/contracts/payment-vault-contract/src/error.rs index 28760ff..439f7d7 100644 --- a/contracts/payment-vault-contract/src/error.rs +++ b/contracts/payment-vault-contract/src/error.rs @@ -11,4 +11,5 @@ pub enum VaultError { BookingNotPending = 5, InvalidAmount = 6, ReclaimTooEarly = 7, -} \ No newline at end of file + ExpertRateNotSet = 8, +} diff --git a/contracts/payment-vault-contract/src/events.rs b/contracts/payment-vault-contract/src/events.rs index 2ff8dfe..5e465ea 100644 --- a/contracts/payment-vault-contract/src/events.rs +++ b/contracts/payment-vault-contract/src/events.rs @@ -1,9 +1,17 @@ -use soroban_sdk::{Address, Env, symbol_short}; +#![allow(deprecated)] +use soroban_sdk::{symbol_short, Address, Env}; /// Emitted when a new booking is created -pub fn booking_created(env: &Env, booking_id: u64, user: &Address, expert: &Address, deposit: i128) { +pub fn booking_created( + env: &Env, + booking_id: u64, + user: &Address, + expert: &Address, + deposit: i128, +) { let topics = (symbol_short!("booked"), booking_id); - env.events().publish(topics, (user.clone(), expert.clone(), deposit)); + env.events() + .publish(topics, (user.clone(), expert.clone(), deposit)); } /// Emitted when a session is finalized @@ -22,3 +30,9 @@ pub fn session_rejected(env: &Env, booking_id: u64, reason: &str) { let topics = (symbol_short!("reject"), booking_id); env.events().publish(topics, reason); } + +/// 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()); + env.events().publish(topics, rate); +} diff --git a/contracts/payment-vault-contract/src/lib.rs b/contracts/payment-vault-contract/src/lib.rs index 8fa71f6..ebb4000 100644 --- a/contracts/payment-vault-contract/src/lib.rs +++ b/contracts/payment-vault-contract/src/lib.rs @@ -4,13 +4,13 @@ mod contract; mod error; mod events; mod storage; -mod types; #[cfg(test)] mod test; +mod types; -use soroban_sdk::{contract, contractimpl, Address, Env, Vec}; use crate::error::VaultError; use crate::types::BookingRecord; +use soroban_sdk::{contract, contractimpl, Address, Env, Vec}; #[contract] pub struct PaymentVaultContract; @@ -22,21 +22,25 @@ impl PaymentVaultContract { env: Env, admin: Address, token: Address, - oracle: Address + oracle: Address, ) -> Result<(), VaultError> { contract::initialize_vault(&env, &admin, &token, &oracle) } + /// Set an expert's own rate per second + 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 pub fn book_session( env: Env, user: Address, expert: Address, - rate_per_second: i128, max_duration: u64, ) -> Result { - contract::book_session(&env, &user, &expert, rate_per_second, max_duration) + contract::book_session(&env, &user, &expert, max_duration) } /// Finalize a session (Oracle-only) @@ -61,11 +65,7 @@ impl PaymentVaultContract { /// 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> { + pub fn reject_session(env: Env, expert: Address, booking_id: u64) -> Result<(), VaultError> { contract::reject_session(&env, &expert, booking_id) } diff --git a/contracts/payment-vault-contract/src/storage.rs b/contracts/payment-vault-contract/src/storage.rs index 55e8ce3..694d70f 100644 --- a/contracts/payment-vault-contract/src/storage.rs +++ b/contracts/payment-vault-contract/src/storage.rs @@ -1,5 +1,5 @@ -use soroban_sdk::{contracttype, Address, Env}; use crate::types::{BookingRecord, BookingStatus}; +use soroban_sdk::{contracttype, Address, Env}; #[contracttype] #[derive(Clone)] @@ -7,10 +7,11 @@ 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 + 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 + ExpertRate(Address), // Expert Address -> rate per second (i128) } // --- Admin --- @@ -53,7 +54,9 @@ pub fn get_next_booking_id(env: &Env) -> u64 { .get(&DataKey::BookingCounter) .unwrap_or(0); let next = current + 1; - env.storage().instance().set(&DataKey::BookingCounter, &next); + env.storage() + .instance() + .set(&DataKey::BookingCounter, &next); next } @@ -119,3 +122,16 @@ pub fn get_expert_bookings(env: &Env, expert: &Address) -> soroban_sdk::Vec .get(&DataKey::ExpertBookings(expert.clone())) .unwrap_or(soroban_sdk::Vec::new(env)) } + +// --- Expert Rates --- +pub fn set_expert_rate(env: &Env, expert: &Address, rate: i128) { + env.storage() + .persistent() + .set(&DataKey::ExpertRate(expert.clone()), &rate); +} + +pub fn get_expert_rate(env: &Env, expert: &Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::ExpertRate(expert.clone())) +} From 8fbdaadad8755fa0662686345fbad987369179f0 Mon Sep 17 00:00:00 2001 From: dinahmaccodes Date: Wed, 25 Feb 2026 01:45:09 +0100 Subject: [PATCH 2/2] test: add tests for expert rate configuration and deposits --- contracts/payment-vault-contract/src/test.rs | 186 ++++++++++++++++--- 1 file changed, 164 insertions(+), 22 deletions(-) diff --git a/contracts/payment-vault-contract/src/test.rs b/contracts/payment-vault-contract/src/test.rs index 15a0c6f..d6260cc 100644 --- a/contracts/payment-vault-contract/src/test.rs +++ b/contracts/payment-vault-contract/src/test.rs @@ -58,7 +58,10 @@ fn test_partial_duration_scenario() { // Total deposit = 10 * 100 = 1000 tokens let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Verify user's balance decreased assert_eq!(token.balance(&user), 9_000); @@ -94,7 +97,10 @@ fn test_full_duration_no_refund() { // Book session let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Oracle finalizes with full duration (100 seconds) let actual_duration = 100_u64; @@ -125,7 +131,10 @@ fn test_double_finalization_protection() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // First finalization succeeds let actual_duration = 50_u64; @@ -156,7 +165,10 @@ fn test_oracle_authorization_enforcement() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Clear all mocked auths to test Oracle authorization env.set_auths(&[]); @@ -192,7 +204,10 @@ fn test_zero_duration_finalization() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Oracle finalizes with 0 duration (session cancelled) let actual_duration = 0_u64; @@ -255,7 +270,10 @@ fn test_book_session_balance_transfer() { assert_eq!(token.balance(&client.address), 0); // Book session - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + 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); @@ -268,7 +286,10 @@ fn test_book_session_balance_transfer() { // Create another booking to verify uniqueness token.mint(&user, &expected_deposit); // Mint more tokens for second booking - let booking_id_2 = client.book_session(&user, &expert, &rate_per_second, &max_duration); + 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); @@ -296,8 +317,14 @@ fn test_get_user_and_expert_bookings() { // 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 = client.book_session(&user, &expert1, &rate_per_second, &max_duration); - let booking_id_2 = client.book_session(&user, &expert2, &rate_per_second, &max_duration); + let booking_id_1 = { + client.set_my_rate(&expert1, &rate_per_second); + client.book_session(&user, &expert1, &max_duration) + }; + let booking_id_2 = { + client.set_my_rate(&expert2, &rate_per_second); + client.book_session(&user, &expert2, &max_duration) + }; // Test get_user_bookings - should return 2 bookings let user_bookings = client.get_user_bookings(&user); @@ -349,7 +376,10 @@ fn test_reclaim_stale_session_too_early() { // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + 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); @@ -380,10 +410,14 @@ fn test_reclaim_stale_session_success() { // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Advance ledger timestamp by 25 hours (90000 seconds) - env.ledger().set_timestamp(env.ledger().timestamp() + 90_000); + 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); @@ -416,10 +450,14 @@ fn test_reclaim_stale_session_wrong_user() { // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Advance ledger timestamp by 25 hours - env.ledger().set_timestamp(env.ledger().timestamp() + 90_000); + 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); @@ -449,13 +487,17 @@ fn test_reclaim_already_finalized() { // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + 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); + 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); @@ -482,7 +524,10 @@ fn test_expert_rejects_pending_session() { // Create booking let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Verify initial state assert_eq!(token.balance(&user), 9_000); @@ -522,7 +567,10 @@ fn test_user_cannot_reject_session() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + 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); @@ -551,7 +599,10 @@ fn test_reject_already_complete_session() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Oracle finalizes the session client.finalize_session(&booking_id, &50); @@ -580,10 +631,14 @@ fn test_reject_already_reclaimed_session() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + client.book_session(&user, &expert, &max_duration) + }; // Advance time and user reclaims - env.ledger().set_timestamp(env.ledger().timestamp() + 90_000); + 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) @@ -611,7 +666,10 @@ fn test_wrong_expert_cannot_reject() { let rate_per_second = 10_i128; let max_duration = 100_u64; - let booking_id = client.book_session(&user, &expert, &rate_per_second, &max_duration); + let booking_id = { + client.set_my_rate(&expert, &rate_per_second); + 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); @@ -639,3 +697,87 @@ fn test_reject_nonexistent_booking() { assert!(result.is_err()); } +#[test] +fn test_expert_can_set_and_update_rate() { + 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 = Address::generate(&env); + + 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()); +} + +#[test] +fn test_book_session_calculates_correct_deposit() { + 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); + let initial_balance = 5_000_i128; + token.mint(&user, &initial_balance); + + 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 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); +} + +#[test] +fn test_book_session_fails_if_expert_rate_not_set() { + 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); + token.mint(&user, &5_000); + + 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); + + assert!(res.is_err()); +}