diff --git a/contracts/identity-registry-contract/src/contract.rs b/contracts/identity-registry-contract/src/contract.rs index c241478..1814245 100644 --- a/contracts/identity-registry-contract/src/contract.rs +++ b/contracts/identity-registry-contract/src/contract.rs @@ -31,7 +31,7 @@ pub fn batch_add_experts(env: Env, experts: Vec
) -> Result<(), Registry } // Default empty URI for batch adds let empty_uri = String::from_str(&env, ""); - storage::set_expert_record(&env, &expert, ExpertStatus::Verified, empty_uri); + storage::set_expert_record(&env, &expert, ExpertStatus::Verified, empty_uri, 0); storage::add_expert_to_index(&env, &expert); events::emit_status_change(&env, expert, status, ExpertStatus::Verified, admin.clone()); } @@ -54,7 +54,7 @@ pub fn batch_ban_experts(env: Env, experts: Vec
) -> Result<(), Registry return Err(RegistryError::AlreadyBanned); } let existing = storage::get_expert_record(&env, &expert); - storage::set_expert_record(&env, &expert, ExpertStatus::Banned, existing.data_uri); + storage::set_expert_record(&env, &expert, ExpertStatus::Banned, existing.data_uri, existing.category_id); events::emit_status_change(&env, expert, status, ExpertStatus::Banned, admin.clone()); } @@ -82,6 +82,7 @@ pub fn verify_expert( caller: &Address, expert: &Address, data_uri: String, + category_id: u32, ) -> Result<(), RegistryError> { let admin = storage::get_admin(env).ok_or(RegistryError::NotInitialized)?; @@ -104,7 +105,7 @@ pub fn verify_expert( return Err(RegistryError::UriTooLong); } - storage::set_expert_record(env, expert, ExpertStatus::Verified, data_uri); + storage::set_expert_record(env, expert, ExpertStatus::Verified, data_uri, category_id); storage::add_expert_to_index(env, expert); events::emit_status_change( @@ -136,9 +137,9 @@ pub fn ban_expert(env: &Env, caller: &Address, expert: &Address) -> Result<(), R return Err(RegistryError::AlreadyBanned); } - // Preserve existing data_uri when banning + // Preserve existing data_uri and category_id when banning let existing = storage::get_expert_record(env, expert); - storage::set_expert_record(env, expert, ExpertStatus::Banned, existing.data_uri); + storage::set_expert_record(env, expert, ExpertStatus::Banned, existing.data_uri, existing.category_id); events::emit_status_change( env, @@ -162,9 +163,9 @@ pub fn unban_expert(env: &Env, expert: &Address) -> Result<(), RegistryError> { return Err(RegistryError::NotBanned); } - // Preserve existing data_uri when unbanning + // Preserve existing data_uri and category_id when unbanning let existing = storage::get_expert_record(env, expert); - storage::set_expert_record(env, expert, ExpertStatus::Verified, existing.data_uri); + storage::set_expert_record(env, expert, ExpertStatus::Verified, existing.data_uri, existing.category_id); events::emit_status_change( env, @@ -199,7 +200,7 @@ pub fn is_verified(env: &Env, expert: &Address) -> bool { } /// Allow a verified expert to update their own profile URI -pub fn update_profile(env: &Env, expert: &Address, new_uri: String) -> Result<(), RegistryError> { +pub fn update_profile(env: &Env, expert: &Address, new_uri: String, category_id: u32) -> Result<(), RegistryError> { expert.require_auth(); // Validate URI length @@ -213,7 +214,7 @@ pub fn update_profile(env: &Env, expert: &Address, new_uri: String) -> Result<() } // Update record preserving status - storage::set_expert_record(env, expert, status, new_uri.clone()); + storage::set_expert_record(env, expert, status, new_uri.clone(), category_id); events::emit_profile_updated(env, expert.clone(), new_uri); Ok(()) } @@ -222,7 +223,7 @@ pub fn update_profile(env: &Env, expert: &Address, new_uri: String) -> Result<() /// Allows admins to update multiple expert metadata URIs in a single transaction pub fn batch_update_profiles( env: &Env, - updates: Vec<(Address, String, u32)>, + updates: Vec<(Address, String, u32, u32)>, ) -> Result<(), RegistryError> { // Limit batch size to prevent DoS if updates.len() > 20 { @@ -233,7 +234,7 @@ pub fn batch_update_profiles( admin.require_auth(); for update in updates { - let (expert, new_uri, status_u32) = update; + let (expert, new_uri, status_u32, category_id) = update; // Validate URI length if new_uri.len() > 64 { @@ -249,7 +250,7 @@ pub fn batch_update_profiles( }; // Update the expert record - storage::set_expert_record(env, &expert, status, new_uri); + storage::set_expert_record(env, &expert, status, new_uri, category_id); } Ok(()) diff --git a/contracts/identity-registry-contract/src/lib.rs b/contracts/identity-registry-contract/src/lib.rs index 4963b6c..dec5ed4 100644 --- a/contracts/identity-registry-contract/src/lib.rs +++ b/contracts/identity-registry-contract/src/lib.rs @@ -49,8 +49,9 @@ impl IdentityRegistryContract { caller: Address, expert: Address, data_uri: String, + category_id: u32, ) -> Result<(), RegistryError> { - contract::verify_expert(&env, &caller, &expert, data_uri) + contract::verify_expert(&env, &caller, &expert, data_uri, category_id) } /// Ban an expert and revoke their verification status (Admin or Moderator) @@ -85,15 +86,15 @@ impl IdentityRegistryContract { } /// Allow a verified expert to update their own profile URI - pub fn update_profile(env: Env, expert: Address, new_uri: String) -> Result<(), RegistryError> { - contract::update_profile(&env, &expert, new_uri) + pub fn update_profile(env: Env, expert: Address, new_uri: String, category_id: u32) -> Result<(), RegistryError> { + contract::update_profile(&env, &expert, new_uri, category_id) } /// Batch update expert profiles (Admin only) /// Allows admins to update multiple expert metadata URIs in a single transaction pub fn batch_update_profiles( env: Env, - updates: Vec<(Address, String, u32)>, + updates: Vec<(Address, String, u32, u32)>, ) -> Result<(), RegistryError> { contract::batch_update_profiles(&env, updates) } diff --git a/contracts/identity-registry-contract/src/storage.rs b/contracts/identity-registry-contract/src/storage.rs index c3cc222..d8fb0a8 100644 --- a/contracts/identity-registry-contract/src/storage.rs +++ b/contracts/identity-registry-contract/src/storage.rs @@ -68,13 +68,14 @@ pub fn remove_moderator(env: &Env, address: &Address) { // ... [Expert Helpers] ... /// Set the expert record with status, data_uri and timestamp -pub fn set_expert_record(env: &Env, expert: &Address, status: ExpertStatus, data_uri: String) { +pub fn set_expert_record(env: &Env, expert: &Address, status: ExpertStatus, data_uri: String, category_id: u32) { let key = DataKey::Expert(expert.clone()); let record = ExpertRecord { status, updated_at: env.ledger().timestamp(), data_uri, + category_id, }; // 1. Save the data @@ -107,6 +108,7 @@ pub fn get_expert_record(env: &Env, expert: &Address) -> ExpertRecord { status: ExpertStatus::Unverified, updated_at: 0, data_uri: String::from_str(env, ""), + category_id: 0, }) } diff --git a/contracts/identity-registry-contract/src/test.rs b/contracts/identity-registry-contract/src/test.rs index 7f491d1..5fc4277 100644 --- a/contracts/identity-registry-contract/src/test.rs +++ b/contracts/identity-registry-contract/src/test.rs @@ -41,7 +41,7 @@ fn test_data_uri_persisted_on_verify() { let uri = String::from_str(&env, "ipfs://persisted"); client.init(&admin); - client.add_expert(&admin, &expert, &uri); + client.add_expert(&admin, &expert, &uri, &0u32); // Read storage as contract and assert data_uri persisted env.as_contract(&contract_id, || { @@ -64,10 +64,10 @@ fn test_update_profile_updates_uri_and_emits_event() { let uri2 = String::from_str(&env, "ipfs://updated"); client.init(&admin); - client.add_expert(&admin, &expert, &uri1); + client.add_expert(&admin, &expert, &uri1, &0u32); // Update profile URI - client.update_profile(&expert, &uri2); + client.update_profile(&expert, &uri2, &1u32); // Assert record updated env.as_contract(&contract_id, || { @@ -92,18 +92,18 @@ fn test_update_profile_rejections() { // NotVerified when updating without being verified let new_uri = String::from_str(&env, "ipfs://new"); - let res = client.try_update_profile(&unverified, &new_uri); + let res = client.try_update_profile(&unverified, &new_uri, &0u32); assert_eq!(res, Err(Ok(RegistryError::NotVerified))); // Verify then try overlong uri let expert = Address::generate(&env); let ok_uri = String::from_str(&env, "ipfs://ok"); - client.add_expert(&admin, &expert, &ok_uri); + client.add_expert(&admin, &expert, &ok_uri, &0u32); // Build >64 length string let long_str = "a".repeat(65); let long_uri = String::from_str(&env, long_str.as_str()); - let res2 = client.try_update_profile(&expert, &long_uri); + let res2 = client.try_update_profile(&expert, &long_uri, &0u32); assert_eq!(res2, Err(Ok(RegistryError::UriTooLong))); } @@ -239,7 +239,7 @@ fn test_add_expert() { client.init(&admin); let data_uri = String::from_str(&env, "ipfs://profile1"); - let res = client.try_add_expert(&admin, &expert, &data_uri); + let res = client.try_add_expert(&admin, &expert, &data_uri, &0u32); assert!(res.is_ok()); assert_eq!( @@ -250,7 +250,7 @@ fn test_add_expert() { function: AuthorizedFunction::Contract(( contract_id.clone(), Symbol::new(&env, "add_expert"), - (admin.clone(), expert.clone(), data_uri.clone()).into_val(&env) + (admin.clone(), expert.clone(), data_uri.clone(), 0u32).into_val(&env) )), sub_invocations: std::vec![] } @@ -272,7 +272,7 @@ fn test_add_expert_unauthorized() { client.init(&admin); let data_uri = String::from_str(&env, "ipfs://unauth"); // admin is the caller but no auth is mocked — should panic - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); } #[test] @@ -288,7 +288,7 @@ fn test_expert_status_changed_event() { client.init(&admin); let data_uri = String::from_str(&env, "ipfs://event"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); let events = env.events().all(); let event = events.last().unwrap(); @@ -314,7 +314,7 @@ fn test_ban_expert() { // Verify the expert first env.mock_all_auths(); let data_uri = String::from_str(&env, "ipfs://ban"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); // Verify status is Verified let status = client.get_status(&expert); @@ -346,7 +346,7 @@ fn test_ban_expert_unauthorized() { env.mock_all_auths(); let data_uri = String::from_str(&env, "ipfs://ban-unauth"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); env.mock_all_auths_allowing_non_root_auth(); @@ -401,9 +401,9 @@ fn test_ban_expert_workflow() { let uri1 = String::from_str(&env, "ipfs://u1"); let uri2 = String::from_str(&env, "ipfs://u2"); let uri3 = String::from_str(&env, "ipfs://u3"); - client.add_expert(&admin, &expert1, &uri1); - client.add_expert(&admin, &expert2, &uri2); - client.add_expert(&admin, &expert3, &uri3); + client.add_expert(&admin, &expert1, &uri1, &0u32); + client.add_expert(&admin, &expert2, &uri2, &0u32); + client.add_expert(&admin, &expert3, &uri3, &0u32); // Check all are verified assert_eq!(client.get_status(&expert1), ExpertStatus::Verified); @@ -462,7 +462,7 @@ fn test_complete_expert_lifecycle() { // 2. Verify the expert let data_uri = String::from_str(&env, "ipfs://life"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); assert_eq!(client.get_status(&expert), ExpertStatus::Verified); // 3. Ban the expert @@ -489,7 +489,7 @@ fn test_getters() { // Test 2: Verify an expert and check is_verified (should be true) let expert = Address::generate(&env); let data_uri = String::from_str(&env, "ipfs://getters"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); assert_eq!(client.is_verified(&expert), true); assert_eq!(client.get_status(&expert), ExpertStatus::Verified); @@ -518,9 +518,9 @@ fn test_expert_directory_enumeration() { let uri1 = String::from_str(&env, "ipfs://e1"); let uri2 = String::from_str(&env, "ipfs://e2"); let uri3 = String::from_str(&env, "ipfs://e3"); - client.add_expert(&admin, &expert1, &uri1); - client.add_expert(&admin, &expert2, &uri2); - client.add_expert(&admin, &expert3, &uri3); + client.add_expert(&admin, &expert1, &uri1, &0u32); + client.add_expert(&admin, &expert2, &uri2, &0u32); + client.add_expert(&admin, &expert3, &uri3, &0u32); // Total should be 3 assert_eq!(client.get_total_experts(), 3u64); @@ -545,13 +545,13 @@ fn test_expert_directory_no_duplicates_on_reverify() { client.init(&admin); let uri = String::from_str(&env, "ipfs://expert"); - client.add_expert(&admin, &expert, &uri); + client.add_expert(&admin, &expert, &uri, &0u32); // Total is 1 assert_eq!(client.get_total_experts(), 1u64); // Re-verifying an already verified expert returns AlreadyVerified - let result = client.try_add_expert(&admin, &expert, &uri); + let result = client.try_add_expert(&admin, &expert, &uri, &0u32); assert_eq!(result, Err(Ok(RegistryError::AlreadyVerified))); // Total remains 1 — no duplicate in the index @@ -609,11 +609,11 @@ fn test_batch_update_profiles() { let uri4 = String::from_str(&env, "ipfs://original4"); let uri5 = String::from_str(&env, "ipfs://original5"); - client.add_expert(&admin, &expert1, &uri1); - client.add_expert(&admin, &expert2, &uri2); - client.add_expert(&admin, &expert3, &uri3); - client.add_expert(&admin, &expert4, &uri4); - client.add_expert(&admin, &expert5, &uri5); + client.add_expert(&admin, &expert1, &uri1, &0u32); + client.add_expert(&admin, &expert2, &uri2, &0u32); + client.add_expert(&admin, &expert3, &uri3, &0u32); + client.add_expert(&admin, &expert4, &uri4, &0u32); + client.add_expert(&admin, &expert5, &uri5, &0u32); // Prepare batch updates with new URIs let new_uri1 = String::from_str(&env, "ipfs://updated1"); @@ -624,11 +624,11 @@ fn test_batch_update_profiles() { let updates = vec![ &env, - (expert1.clone(), new_uri1.clone(), 1u32), // Verified - (expert2.clone(), new_uri2.clone(), 1u32), // Verified - (expert3.clone(), new_uri3.clone(), 1u32), // Verified - (expert4.clone(), new_uri4.clone(), 1u32), // Verified - (expert5.clone(), new_uri5.clone(), 1u32), // Verified + (expert1.clone(), new_uri1.clone(), 1u32, 0u32), + (expert2.clone(), new_uri2.clone(), 1u32, 0u32), + (expert3.clone(), new_uri3.clone(), 1u32, 0u32), + (expert4.clone(), new_uri4.clone(), 1u32, 0u32), + (expert5.clone(), new_uri5.clone(), 1u32, 0u32), ]; // Execute batch update @@ -674,7 +674,7 @@ fn test_batch_update_profiles_max_vec() { for _ in 0..21 { let expert = Address::generate(&env); let uri = String::from_str(&env, "ipfs://test"); - updates.push_back((expert, uri, 1u32)); + updates.push_back((expert, uri, 1u32, 0u32)); } // This should fail with ExpertVecMax error @@ -694,13 +694,13 @@ fn test_batch_update_profiles_uri_too_long() { let expert = Address::generate(&env); let uri = String::from_str(&env, "ipfs://initial"); - client.add_expert(&admin, &expert, &uri); + client.add_expert(&admin, &expert, &uri, &0u32); // Create update with URI that's too long (>64 chars) let long_str = "a".repeat(65); let long_uri = String::from_str(&env, long_str.as_str()); - let updates = vec![&env, (expert.clone(), long_uri, 1u32)]; + let updates = vec![&env, (expert.clone(), long_uri, 1u32, 0u32)]; // This should fail with UriTooLong error let result = client.try_batch_update_profiles(&updates); @@ -723,7 +723,7 @@ fn test_expert_pagination() { for _ in 0..15 { let expert = Address::generate(&env); let uri = String::from_str(&env, "ipfs://expert"); - client.add_expert(&admin, &expert, &uri); + client.add_expert(&admin, &expert, &uri, &0u32); experts.push_back(expert); } @@ -762,7 +762,7 @@ fn test_unban_expert() { env.mock_all_auths(); let data_uri = String::from_str(&env, "ipfs://unban"); - client.add_expert(&admin, &expert, &data_uri); + client.add_expert(&admin, &expert, &data_uri, &0u32); // Initial status: Verified assert_eq!(client.get_status(&expert), ExpertStatus::Verified); @@ -809,7 +809,7 @@ fn test_moderator_can_verify_expert() { // Moderator verifies an expert (should succeed) let uri = String::from_str(&env, "ipfs://mod-verify"); - let res = client.try_add_expert(&moderator, &expert, &uri); + let res = client.try_add_expert(&moderator, &expert, &uri, &0u32); assert!(res.is_ok()); assert_eq!(client.get_status(&expert), ExpertStatus::Verified); @@ -832,7 +832,7 @@ fn test_moderator_can_ban_expert() { // Verify expert first (by admin) let uri = String::from_str(&env, "ipfs://mod-ban"); - client.add_expert(&admin, &expert, &uri); + client.add_expert(&admin, &expert, &uri, &0u32); // Moderator bans the expert (should succeed) let res = client.try_ban_expert(&moderator, &expert); @@ -857,7 +857,7 @@ fn test_non_moderator_cannot_verify_expert() { // Random address (not admin, not moderator) tries to verify — should fail with Unauthorized let uri = String::from_str(&env, "ipfs://unauth"); - let res = client.try_add_expert(&random, &expert, &uri); + let res = client.try_add_expert(&random, &expert, &uri, &0u32); assert_eq!(res, Err(Ok(RegistryError::Unauthorized))); } @@ -886,6 +886,57 @@ fn test_remove_moderator() { // After removal, moderator can no longer verify experts let uri = String::from_str(&env, "ipfs://removed-mod"); - let res = client.try_add_expert(&moderator, &expert, &uri); + let res = client.try_add_expert(&moderator, &expert, &uri, &0u32); assert_eq!(res, Err(Ok(RegistryError::Unauthorized))); } + +#[test] +fn test_category_id_persisted_and_updated() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(IdentityRegistryContract, ()); + let client = IdentityRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let expert = Address::generate(&env); + + client.init(&admin); + + // Add expert with category_id = 5 + let uri = String::from_str(&env, "ipfs://cat"); + client.add_expert(&admin, &expert, &uri, &5u32); + + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.category_id, 5); + }); + + // Update profile with new category_id = 10 + let uri2 = String::from_str(&env, "ipfs://cat2"); + client.update_profile(&expert, &uri2, &10u32); + + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.category_id, 10); + assert_eq!(rec.data_uri, uri2); + }); + + // Ban and verify category_id is preserved + client.ban_expert(&admin, &expert); + + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.category_id, 10); + assert_eq!(rec.status, ExpertStatus::Banned); + }); + + // Unban and verify category_id is preserved + client.unban_expert(&expert); + + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.category_id, 10); + assert_eq!(rec.status, ExpertStatus::Verified); + }); +} diff --git a/contracts/identity-registry-contract/src/types.rs b/contracts/identity-registry-contract/src/types.rs index 4199f80..37cd212 100644 --- a/contracts/identity-registry-contract/src/types.rs +++ b/contracts/identity-registry-contract/src/types.rs @@ -18,4 +18,5 @@ pub struct ExpertRecord { pub status: ExpertStatus, pub updated_at: u64, // Ledger timestamp of the last change pub data_uri: String, + pub category_id: u32, } diff --git a/contracts/reputation-scoring-contract/src/contract.rs b/contracts/reputation-scoring-contract/src/contract.rs index f6f579a..bafefd3 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, @@ -49,3 +50,76 @@ pub fn upgrade_contract(env: &Env, new_wasm_hash: BytesN<32>) -> Result<(), Repu env.deployer().update_current_contract_wasm(new_wasm_hash); 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 572a33e..902bd00 100644 --- a/contracts/reputation-scoring-contract/src/error.rs +++ b/contracts/reputation-scoring-contract/src/error.rs @@ -7,4 +7,8 @@ pub enum ReputationError { NotInitialized = 1, AlreadyInitialized = 2, ContractPaused = 3, + InvalidScore = 4, + BookingNotComplete = 5, + AlreadyReviewed = 6, + NotBookingUser = 7, } diff --git a/contracts/reputation-scoring-contract/src/events.rs b/contracts/reputation-scoring-contract/src/events.rs index 01776bd..67c4dd4 100644 --- a/contracts/reputation-scoring-contract/src/events.rs +++ b/contracts/reputation-scoring-contract/src/events.rs @@ -13,3 +13,10 @@ pub fn admin_transferred(env: &Env, old_admin: &Address, new_admin: &Address) { env.events() .publish(topics, (old_admin.clone(), new_admin.clone())); } + +/// 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 1a52e85..113f861 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] @@ -35,4 +36,21 @@ impl ReputationScoringContract { pub fn upgrade_contract(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ReputationError> { contract::upgrade_contract(&env, new_wasm_hash) } + + 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 a95f8bd..4dd72db 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] @@ -6,8 +7,12 @@ pub enum DataKey { Admin, VaultAddress, IsPaused, + Review(u64), // booking_id → ReviewRecord + ExpertStats(Address), // expert → ExpertStats } +// --- Admin --- + pub fn has_admin(env: &Env) -> bool { env.storage().instance().has(&DataKey::Admin) } @@ -20,10 +25,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() @@ -34,3 +47,41 @@ pub fn is_paused(env: &Env) -> bool { pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::IsPaused, &paused); } + +// --- 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 c9a3a67..bd4dfbe 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(); @@ -52,3 +136,165 @@ fn test_unpause_restores_transfer_admin() { let new_admin = Address::generate(&env); client.transfer_admin(&new_admin); } + +// ── 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, +}