diff --git a/contracts/reputation-scoring-contract/src/contract.rs b/contracts/reputation-scoring-contract/src/contract.rs index 02e66b8..2e5607f 100644 --- a/contracts/reputation-scoring-contract/src/contract.rs +++ b/contracts/reputation-scoring-contract/src/contract.rs @@ -1,7 +1,8 @@ use crate::error::ReputationError; use crate::events; use crate::storage; -use soroban_sdk::{Address, BytesN, Env}; +use crate::types::{BookingRecord, BookingStatus, ExpertStats, ReviewRecord}; +use soroban_sdk::{Address, BytesN, Env, IntoVal, Symbol}; pub fn initialize( env: &Env, @@ -79,3 +80,76 @@ pub fn penalize_expert( Ok(()) } + +pub fn submit_review( + env: &Env, + reviewer: &Address, + booking_id: u64, + score: u32, +) -> Result<(), ReputationError> { + storage::get_admin(env).ok_or(ReputationError::NotInitialized)?; + + if storage::is_paused(env) { + return Err(ReputationError::ContractPaused); + } + + reviewer.require_auth(); + + // Validate score 1–5 + if score < 1 || score > 5 { + return Err(ReputationError::InvalidScore); + } + + // Prevent duplicate reviews + if storage::has_review(env, booking_id) { + return Err(ReputationError::AlreadyReviewed); + } + + // Cross-contract call to vault: get_booking + let vault_address = storage::get_vault_address(env) + .ok_or(ReputationError::NotInitialized)?; + + let booking: BookingRecord = env.invoke_contract( + &vault_address, + &Symbol::new(env, "get_booking"), + soroban_sdk::vec![env, booking_id.into_val(env)], + ); + + // Check booking is Complete + if booking.status != BookingStatus::Complete { + return Err(ReputationError::BookingNotComplete); + } + + // Verify reviewer is the booking user + if *reviewer != booking.user { + return Err(ReputationError::NotBookingUser); + } + + // Store review + let review = ReviewRecord { + booking_id, + reviewer: reviewer.clone(), + expert: booking.expert.clone(), + score, + timestamp: env.ledger().timestamp(), + }; + storage::set_review(env, booking_id, &review); + + // Update expert stats + let mut stats = storage::get_expert_stats(env, &booking.expert); + stats.total_score += score as u64; + stats.review_count += 1; + storage::set_expert_stats(env, &booking.expert, &stats); + + events::review_submitted(env, booking_id, reviewer, &booking.expert, score); + + Ok(()) +} + +pub fn get_review(env: &Env, booking_id: u64) -> Option { + storage::get_review(env, booking_id) +} + +pub fn get_expert_stats(env: &Env, expert: &Address) -> ExpertStats { + storage::get_expert_stats(env, expert) +} diff --git a/contracts/reputation-scoring-contract/src/error.rs b/contracts/reputation-scoring-contract/src/error.rs index 8c7bc5c..711e856 100644 --- a/contracts/reputation-scoring-contract/src/error.rs +++ b/contracts/reputation-scoring-contract/src/error.rs @@ -7,5 +7,9 @@ pub enum ReputationError { NotInitialized = 1, AlreadyInitialized = 2, ContractPaused = 3, + InvalidScore = 4, + BookingNotComplete = 5, + AlreadyReviewed = 6, + NotBookingUser = 7, NotAuthorized = 8, } diff --git a/contracts/reputation-scoring-contract/src/events.rs b/contracts/reputation-scoring-contract/src/events.rs index 5ccd941..890fc95 100644 --- a/contracts/reputation-scoring-contract/src/events.rs +++ b/contracts/reputation-scoring-contract/src/events.rs @@ -13,10 +13,23 @@ pub fn admin_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { env.events() .publish(topics, (old_admin.clone(), new_admin.clone())); } + +/// Emitted when an expert is penalized following a dispute resolution. pub fn expert_penalized(env: &Env, expert: &Address, penalty_points: u64, new_score: u64) { let topics = (symbol_short!("penalized"),); - env.events().publish( - topics, - (expert.clone(), penalty_points, new_score), - ); + env.events() + .publish(topics, (expert.clone(), penalty_points, new_score)); +} + +/// Emitted when a review is submitted. +pub fn review_submitted( + env: &Env, + booking_id: u64, + reviewer: &Address, + expert: &Address, + score: u32, +) { + let topics = (symbol_short!("review"),); + env.events() + .publish(topics, (booking_id, reviewer.clone(), expert.clone(), score)); } diff --git a/contracts/reputation-scoring-contract/src/lib.rs b/contracts/reputation-scoring-contract/src/lib.rs index 640193c..394de43 100644 --- a/contracts/reputation-scoring-contract/src/lib.rs +++ b/contracts/reputation-scoring-contract/src/lib.rs @@ -9,6 +9,7 @@ mod test; mod types; use crate::error::ReputationError; +use crate::types::{ExpertStats, ReviewRecord}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env}; #[contract] @@ -43,4 +44,21 @@ impl ReputationScoringContract { ) -> Result<(), ReputationError> { contract::penalize_expert(&env, &expert, penalty_points) } + + pub fn submit_review( + env: Env, + reviewer: Address, + booking_id: u64, + score: u32, + ) -> Result<(), ReputationError> { + contract::submit_review(&env, &reviewer, booking_id, score) + } + + pub fn get_review(env: Env, booking_id: u64) -> Option { + contract::get_review(&env, booking_id) + } + + pub fn get_expert_stats(env: Env, expert: Address) -> ExpertStats { + contract::get_expert_stats(&env, &expert) + } } diff --git a/contracts/reputation-scoring-contract/src/storage.rs b/contracts/reputation-scoring-contract/src/storage.rs index faa6e4f..3bbd274 100644 --- a/contracts/reputation-scoring-contract/src/storage.rs +++ b/contracts/reputation-scoring-contract/src/storage.rs @@ -1,3 +1,4 @@ +use crate::types::{ExpertStats, ReviewRecord}; use soroban_sdk::{contracttype, Address, Env}; #[contracttype] @@ -8,8 +9,12 @@ pub enum DataKey { IsPaused, ExpertScore(Address), ExpertReviews(Address), + Review(u64), // booking_id → ReviewRecord + ExpertStats(Address), // expert → ExpertStats } +// --- Admin --- + pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } @@ -22,10 +27,18 @@ pub fn get_admin(env: &Env) -> Option
{ env.storage().instance().get(&DataKey::Admin) } +// --- Vault --- + pub fn set_vault_address(env: &Env, vault: &Address) { env.storage().instance().set(&DataKey::VaultAddress, vault); } +pub fn get_vault_address(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::VaultAddress) +} + +// --- Pause --- + pub fn is_paused(env: &Env) -> bool { env.storage() .instance() @@ -37,6 +50,8 @@ pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::IsPaused, &paused); } +// --- Expert Score (dispute penalties) --- + pub fn get_expert_score(env: &Env, expert: &Address) -> u64 { env.storage() .instance() @@ -63,6 +78,40 @@ pub fn set_expert_reviews(env: &Env, expert: &Address, count: u64) { .set(&DataKey::ExpertReviews(expert.clone()), &count); } -pub fn get_vault_address(env: &Env) -> Option
{ - env.storage().instance().get(&DataKey::VaultAddress) +// --- Reviews --- + +pub fn has_review(env: &Env, booking_id: u64) -> bool { + env.storage() + .persistent() + .has(&DataKey::Review(booking_id)) +} + +pub fn set_review(env: &Env, booking_id: u64, review: &ReviewRecord) { + env.storage() + .persistent() + .set(&DataKey::Review(booking_id), review); +} + +pub fn get_review(env: &Env, booking_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Review(booking_id)) +} + +// --- Expert Stats --- + +pub fn get_expert_stats(env: &Env, expert: &Address) -> ExpertStats { + env.storage() + .persistent() + .get(&DataKey::ExpertStats(expert.clone())) + .unwrap_or(ExpertStats { + total_score: 0, + review_count: 0, + }) +} + +pub fn set_expert_stats(env: &Env, expert: &Address, stats: &ExpertStats) { + env.storage() + .persistent() + .set(&DataKey::ExpertStats(expert.clone()), stats); } diff --git a/contracts/reputation-scoring-contract/src/test.rs b/contracts/reputation-scoring-contract/src/test.rs index 3e347b8..329c3aa 100644 --- a/contracts/reputation-scoring-contract/src/test.rs +++ b/contracts/reputation-scoring-contract/src/test.rs @@ -1,7 +1,57 @@ #![cfg(test)] +extern crate std; + use super::*; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use crate::error::ReputationError; +use crate::types::BookingStatus; +use soroban_sdk::{ + contract, contractimpl, testutils::Address as _, testutils::Events, Address, Env, Symbol, + TryIntoVal, +}; + +// ── Mock Vault Contract ────────────────────────────────────────────────── + +/// A minimal mock of the PaymentVault that returns a canned BookingRecord. +/// It stores a single booking at id=1 with configurable user, expert, and status. +#[contract] +pub struct MockVault; + +#[contractimpl] +impl MockVault { + /// Store a mock booking. status: 0=Pending, 1=Complete, etc. + pub fn set_booking(env: Env, user: Address, expert: Address, status: u32) { + use crate::types::BookingRecord; + let booking = BookingRecord { + id: 1, + user, + expert, + rate_per_second: 100, + max_duration: 3600, + total_deposit: 360_000, + status: match status { + 0 => BookingStatus::Pending, + 1 => BookingStatus::Complete, + 2 => BookingStatus::Rejected, + 3 => BookingStatus::Reclaimed, + _ => BookingStatus::Cancelled, + }, + created_at: 1000, + started_at: None, + }; + env.storage().persistent().set(&1u64, &booking); + } + + /// Matches the vault's get_booking(booking_id) → BookingRecord + pub fn get_booking(env: Env, booking_id: u64) -> crate::types::BookingRecord { + env.storage() + .persistent() + .get(&booking_id) + .expect("booking not found") + } +} + +// ── Helpers ────────────────────────────────────────────────────────────── fn setup() -> (Env, Address, Address, ReputationScoringContractClient<'static>) { let env = Env::default(); @@ -13,6 +63,40 @@ fn setup() -> (Env, Address, Address, ReputationScoringContractClient<'static>) (env, admin, vault, client) } +fn setup_with_vault() -> ( + Env, + Address, + Address, + Address, + Address, + ReputationScoringContractClient<'static>, +) { + let env = Env::default(); + env.mock_all_auths(); + + // Register mock vault + let vault_id = env.register(MockVault, ()); + let vault_client = MockVaultClient::new(&env, &vault_id); + + // Register reputation contract + let contract_id = env.register(ReputationScoringContract, ()); + let client = ReputationScoringContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + + // Initialize reputation contract pointing at mock vault + client.init(&admin, &vault_id); + + // Set up a completed booking (id=1) with user and expert + vault_client.set_booking(&user, &expert, &1u32); + + (env, admin, user, expert, vault_id, client) +} + +// ── Existing tests ─────────────────────────────────────────────────────── + #[test] fn test_initialize() { let (_env, admin, vault, client) = setup(); @@ -53,6 +137,8 @@ fn test_unpause_restores_transfer_admin() { client.transfer_admin(&new_admin); } +// ── penalize_expert tests ──────────────────────────────────────────────── + #[test] fn test_penalize_expert_by_admin() { let (env, admin, vault, client) = setup(); @@ -123,4 +209,166 @@ fn test_penalize_expert_partial_deduction() { // Second penalize (70 - 20 = 50) client.penalize_expert(&expert, &20); -} \ No newline at end of file +} + +// ── submit_review tests ────────────────────────────────────────────────── + +#[test] +fn test_submit_review_success() { + let (_env, _admin, user, expert, _vault_id, client) = setup_with_vault(); + + let res = client.try_submit_review(&user, &1u64, &4u32); + assert!(res.is_ok()); + + // Verify review stored + let review = client.get_review(&1u64).unwrap(); + assert_eq!(review.booking_id, 1); + assert_eq!(review.reviewer, user); + assert_eq!(review.expert, expert); + assert_eq!(review.score, 4); + + // Verify expert stats updated + let stats = client.get_expert_stats(&expert); + assert_eq!(stats.total_score, 4); + assert_eq!(stats.review_count, 1); +} + +#[test] +fn test_submit_review_emits_event() { + let (_env, _admin, user, _expert, _vault_id, client) = setup_with_vault(); + + client.submit_review(&user, &1u64, &5u32); + + let events = _env.events().all(); + let last = events.last().unwrap(); + + let topic: Symbol = last.1.get(0).unwrap().try_into_val(&_env).unwrap(); + assert_eq!(topic, Symbol::new(&_env, "review")); +} + +#[test] +fn test_submit_review_invalid_score_zero() { + let (_env, _admin, user, _expert, _vault_id, client) = setup_with_vault(); + + let res = client.try_submit_review(&user, &1u64, &0u32); + assert_eq!(res, Err(Ok(ReputationError::InvalidScore))); +} + +#[test] +fn test_submit_review_invalid_score_six() { + let (_env, _admin, user, _expert, _vault_id, client) = setup_with_vault(); + + let res = client.try_submit_review(&user, &1u64, &6u32); + assert_eq!(res, Err(Ok(ReputationError::InvalidScore))); +} + +#[test] +fn test_submit_review_duplicate() { + let (_env, _admin, user, _expert, _vault_id, client) = setup_with_vault(); + + client.submit_review(&user, &1u64, &3u32); + let res = client.try_submit_review(&user, &1u64, &5u32); + assert_eq!(res, Err(Ok(ReputationError::AlreadyReviewed))); +} + +#[test] +fn test_submit_review_booking_not_complete() { + let env = Env::default(); + env.mock_all_auths(); + + let vault_id = env.register(MockVault, ()); + let vault_client = MockVaultClient::new(&env, &vault_id); + + let contract_id = env.register(ReputationScoringContract, ()); + let client = ReputationScoringContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let expert = Address::generate(&env); + + client.init(&admin, &vault_id); + + // Set booking as Pending (status=0) + vault_client.set_booking(&user, &expert, &0u32); + + let res = client.try_submit_review(&user, &1u64, &4u32); + assert_eq!(res, Err(Ok(ReputationError::BookingNotComplete))); +} + +#[test] +fn test_submit_review_wrong_user() { + let (env, _admin, _user, _expert, _vault_id, client) = setup_with_vault(); + + let stranger = Address::generate(&env); + let res = client.try_submit_review(&stranger, &1u64, &4u32); + assert_eq!(res, Err(Ok(ReputationError::NotBookingUser))); +} + +#[test] +fn test_submit_review_paused() { + let (_env, _admin, user, _expert, _vault_id, client) = setup_with_vault(); + + client.pause(); + let res = client.try_submit_review(&user, &1u64, &4u32); + assert_eq!(res, Err(Ok(ReputationError::ContractPaused))); +} + +#[test] +fn test_expert_stats_accumulate() { + let env = Env::default(); + env.mock_all_auths(); + + let vault_id = env.register(MockVault, ()); + let vault_client = MockVaultClient::new(&env, &vault_id); + + let contract_id = env.register(ReputationScoringContract, ()); + let client = ReputationScoringContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let expert = Address::generate(&env); + + client.init(&admin, &vault_id); + + // Booking 1: user1 → expert, Complete + vault_client.set_booking(&user1, &expert, &1u32); + client.submit_review(&user1, &1u64, &5u32); + + // Booking 2: user2 → expert, Complete (store at id=2) + // We need a second booking. Override storage for id=2. + env.as_contract(&vault_id, || { + use crate::types::BookingRecord; + let booking = BookingRecord { + id: 2, + user: user2.clone(), + expert: expert.clone(), + rate_per_second: 100, + max_duration: 3600, + total_deposit: 360_000, + status: BookingStatus::Complete, + created_at: 2000, + started_at: None, + }; + env.storage().persistent().set(&2u64, &booking); + }); + + client.submit_review(&user2, &2u64, &3u32); + + let stats = client.get_expert_stats(&expert); + assert_eq!(stats.total_score, 8); // 5 + 3 + assert_eq!(stats.review_count, 2); +} + +#[test] +fn test_score_boundary_values() { + let (_env, _admin, user, expert, _vault_id, client) = setup_with_vault(); + + // Score 1 (minimum valid) + let res = client.try_submit_review(&user, &1u64, &1u32); + assert!(res.is_ok()); + + let stats = client.get_expert_stats(&expert); + assert_eq!(stats.total_score, 1); + assert_eq!(stats.review_count, 1); +} diff --git a/contracts/reputation-scoring-contract/src/types.rs b/contracts/reputation-scoring-contract/src/types.rs index e69de29..c9d5992 100644 --- a/contracts/reputation-scoring-contract/src/types.rs +++ b/contracts/reputation-scoring-contract/src/types.rs @@ -0,0 +1,47 @@ +use soroban_sdk::{contracttype, Address}; + +/// A single review left by a user for a completed booking +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewRecord { + pub booking_id: u64, + pub reviewer: Address, + pub expert: Address, + pub score: u32, // 1–5 + pub timestamp: u64, // ledger timestamp +} + +/// Aggregate reputation stats for an expert +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpertStats { + pub total_score: u64, + pub review_count: u32, +} + +/// Mirror of PaymentVault's BookingStatus for cross-contract deserialization +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum BookingStatus { + Pending = 0, + Complete = 1, + Rejected = 2, + Reclaimed = 3, + Cancelled = 5, +} + +/// Mirror of PaymentVault's BookingRecord for cross-contract deserialization +#[contracttype] +#[derive(Clone, Debug)] +pub struct BookingRecord { + pub id: u64, + pub user: Address, + pub expert: Address, + pub rate_per_second: i128, + pub max_duration: u64, + pub total_deposit: i128, + pub status: BookingStatus, + pub created_at: u64, + pub started_at: Option, +}