From af667f95f2a8ec0528f0c6629c5e6ad25841c2b6 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Sat, 30 May 2026 11:42:08 +0100 Subject: [PATCH 1/3] Add service provider rating system --- contracts/Cargo.toml | 1 + contracts/opsce/Cargo.toml | 14 + contracts/opsce/src/lib.rs | 9 + contracts/opsce/src/provider_rating.rs | 393 +++++++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 contracts/opsce/Cargo.toml create mode 100644 contracts/opsce/src/lib.rs create mode 100644 contracts/opsce/src/provider_rating.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 62be99f9..1e762782 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contrib", "multisig-wallet", "multisig_transfer", + "opsce", ] [workspace.dependencies] diff --git a/contracts/opsce/Cargo.toml b/contracts/opsce/Cargo.toml new file mode 100644 index 00000000..c28b16dd --- /dev/null +++ b/contracts/opsce/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "opsce" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs new file mode 100644 index 00000000..02a5acb7 --- /dev/null +++ b/contracts/opsce/src/lib.rs @@ -0,0 +1,9 @@ +#![no_std] +#![allow(clippy::too_many_arguments)] + +pub mod provider_rating; + +pub use provider_rating::{ + ContractError, MaintenanceRecord, OpsceContract, OpsceContractClient, ProviderProfile, + ProviderRating, Review, +}; diff --git a/contracts/opsce/src/provider_rating.rs b/contracts/opsce/src/provider_rating.rs new file mode 100644 index 00000000..4a265ce6 --- /dev/null +++ b/contracts/opsce/src/provider_rating.rs @@ -0,0 +1,393 @@ +//! Provider rating module +//! +//! Implements a 1-5 star rating system that lets an asset owner rate a +//! maintenance provider after a maintenance record is marked complete. +//! +//! Acceptance criteria covered: +//! - `rate_provider(env, record_id, rating, comment)` — caller must be the +//! asset owner; rating must be 1..=5 +//! - Maintains a running average rating stored on the `ProviderProfile` +//! (scaled by 100, e.g. 4.50 stars => 450) +//! - A given record may be rated only once. Re-rating returns +//! `Err(ContractError::AlreadyRated)`. +//! - `get_provider_rating(env, provider_address)` returns +//! `{ average_rating, total_reviews }` +//! - Emits a `provider_rated` event carrying the rating value and record id + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, Address, Env, String, Symbol, +}; + +/// Errors returned by this contract. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + /// Rating is outside the allowed 1..=5 range. + InvalidRating = 4, + /// The maintenance record was already rated. + AlreadyRated = 5, + /// Maintenance record id was not found. + RecordNotFound = 6, + /// Maintenance record exists but is not yet marked complete. + RecordNotComplete = 7, + /// Provider profile is not registered. + ProviderNotFound = 8, +} + +/// Provider profile with cumulative rating fields. +/// +/// `average_rating` is stored scaled by 100 so a rating of 4.5 stars is +/// represented as 450. This avoids floating point in the WASM contract while +/// preserving two decimal places of precision. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderProfile { + pub address: Address, + pub total_reviews: u32, + pub rating_sum: u32, + pub average_rating: u32, +} + +/// Lightweight maintenance record needed for the rating workflow. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaintenanceRecord { + pub record_id: u64, + pub asset_id: u64, + pub provider: Address, + pub owner: Address, + pub completed: bool, +} + +/// Public read model returned by `get_provider_rating`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderRating { + /// Average rating scaled by 100 (e.g. 450 = 4.50 stars). Zero when no + /// reviews have been submitted yet. + pub average_rating: u32, + pub total_reviews: u32, +} + +/// Persisted rating entry for a single maintenance record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Review { + pub record_id: u64, + pub provider: Address, + pub rater: Address, + pub rating: u32, + pub comment: String, + pub timestamp: u64, +} + +#[contracttype] +pub enum DataKey { + Admin, + Provider(Address), + Record(u64), + /// Marker key written when a record has been rated. + RatedRecord(u64), + /// The persisted review for a record id. + Review(u64), +} + +#[contract] +pub struct OpsceContract; + +#[contractimpl] +impl OpsceContract { + /// One-time initialization. Stores the admin used to register providers + /// and seed maintenance records. + pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { + if env.storage().persistent().has(&DataKey::Admin) { + return Err(ContractError::AlreadyInitialized); + } + env.storage().persistent().set(&DataKey::Admin, &admin); + Ok(()) + } + + /// Register a new provider with a zeroed rating profile. Admin only. + pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized)?; + admin.require_auth(); + + let profile = ProviderProfile { + address: provider.clone(), + total_reviews: 0, + rating_sum: 0, + average_rating: 0, + }; + env.storage() + .persistent() + .set(&DataKey::Provider(provider), &profile); + Ok(()) + } + + /// Persist a maintenance record (already completed). Admin only. + /// In production this would be invoked by the asset-maintenance contract + /// when a record is marked complete; here it acts as the entry point that + /// makes a record eligible for rating. + pub fn record_completed_maintenance( + env: Env, + record_id: u64, + asset_id: u64, + owner: Address, + provider: Address, + ) -> Result<(), ContractError> { + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized)?; + admin.require_auth(); + + if !env + .storage() + .persistent() + .has(&DataKey::Provider(provider.clone())) + { + return Err(ContractError::ProviderNotFound); + } + + let record = MaintenanceRecord { + record_id, + asset_id, + provider, + owner, + completed: true, + }; + env.storage() + .persistent() + .set(&DataKey::Record(record_id), &record); + Ok(()) + } + + /// Rate a provider on a completed maintenance record. + /// + /// Authorization: the asset owner stored on the record must authorize the + /// call (`require_auth`). Rating must be in the inclusive range 1..=5 and + /// each record can be rated only once. + pub fn rate_provider( + env: Env, + record_id: u64, + rating: u32, + comment: String, + ) -> Result<(), ContractError> { + // Validate rating bounds first so 0 and 6+ are rejected before any + // storage access. + if rating < 1 || rating > 5 { + return Err(ContractError::InvalidRating); + } + + let record: MaintenanceRecord = env + .storage() + .persistent() + .get(&DataKey::Record(record_id)) + .ok_or(ContractError::RecordNotFound)?; + + if !record.completed { + return Err(ContractError::RecordNotComplete); + } + + // Caller must be the asset owner stored on the record. + record.owner.require_auth(); + + if env + .storage() + .persistent() + .has(&DataKey::RatedRecord(record_id)) + { + return Err(ContractError::AlreadyRated); + } + + let mut profile: ProviderProfile = env + .storage() + .persistent() + .get(&DataKey::Provider(record.provider.clone())) + .ok_or(ContractError::ProviderNotFound)?; + + // Update the running totals and average (scaled by 100). + profile.rating_sum = profile.rating_sum.saturating_add(rating); + profile.total_reviews = profile.total_reviews.saturating_add(1); + profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews; + + env.storage() + .persistent() + .set(&DataKey::Provider(record.provider.clone()), &profile); + + // Mark this record as rated and persist the review entry. + env.storage() + .persistent() + .set(&DataKey::RatedRecord(record_id), &true); + + let review = Review { + record_id, + provider: record.provider.clone(), + rater: record.owner.clone(), + rating, + comment, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::Review(record_id), &review); + + // Emit the `provider_rated` event with the rating value and record id. + let topic = Symbol::new(&env, "provider_rated"); + env.events() + .publish((topic, record.provider), (rating, record_id)); + + Ok(()) + } + + /// Returns the current `{ average_rating, total_reviews }` for a provider. + /// Unknown providers return zero values rather than an error so callers + /// can use this as a cheap query. + pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating { + match env + .storage() + .persistent() + .get::<_, ProviderProfile>(&DataKey::Provider(provider_address)) + { + Some(p) => ProviderRating { + average_rating: p.average_rating, + total_reviews: p.total_reviews, + }, + None => ProviderRating { + average_rating: 0, + total_reviews: 0, + }, + } + } + + /// Fetch the persisted review for a record, if any. + pub fn get_review(env: Env, record_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Review(record_id)) + } +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{Address, Env, String}; + + struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + owner: Address, + provider: Address, + record_id: u64, + } + + fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let provider = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin); + client.register_provider(&provider); + + let record_id: u64 = 1; + client.record_completed_maintenance(&record_id, &101u64, &owner, &provider); + + Fixture { + env, + client, + owner, + provider, + record_id, + } + } + + #[test] + fn rate_provider_valid_rating_updates_running_average() { + let fx = setup(); + + // First rating: 5 stars. + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100 + + // Second rating on a different record: 4 stars => running avg 4.5. + let record_id2: u64 = 2; + fx.client + .record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider); + fx.client + .rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 2); + assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450 + } + + #[test] + fn rate_provider_duplicate_returns_already_rated() { + let fx = setup(); + + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + // try_ returns the error variant without panicking on contract errors. + let result = fx.client.try_rate_provider( + &fx.record_id, + &4u32, + &String::from_str(&fx.env, "Again"), + ); + assert_eq!(result, Err(Ok(ContractError::AlreadyRated))); + + // The provider's average must remain at the first rating only. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); + } + + #[test] + fn rate_provider_rejects_rating_below_one() { + let fx = setup(); + + let result = fx + .client + .try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, "")); + assert_eq!(result, Err(Ok(ContractError::InvalidRating))); + + // No state change occurred. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); + } + + #[test] + fn rate_provider_rejects_rating_above_five() { + let fx = setup(); + + let result = fx + .client + .try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, "")); + assert_eq!(result, Err(Ok(ContractError::InvalidRating))); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); + } +} From 1a77420c3aadf20abb8dca5ab242e164557acb93 Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Sat, 30 May 2026 11:49:39 +0100 Subject: [PATCH 2/3] Complete KYC identity verification logic --- contracts/Cargo.lock | 7 + contracts/opsce/src/error.rs | 38 +++ contracts/opsce/src/kyc_verification.rs | 315 ++++++++++++++++++++++++ contracts/opsce/src/lib.rs | 7 +- contracts/opsce/src/provider_rating.rs | 31 +-- 5 files changed, 376 insertions(+), 22 deletions(-) create mode 100644 contracts/opsce/src/error.rs create mode 100644 contracts/opsce/src/kyc_verification.rs diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index d697505f..309c0068 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -872,6 +872,13 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opsce" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "p256" version = "0.13.2" diff --git a/contracts/opsce/src/error.rs b/contracts/opsce/src/error.rs new file mode 100644 index 00000000..3838c199 --- /dev/null +++ b/contracts/opsce/src/error.rs @@ -0,0 +1,38 @@ +//! Shared contract error type for the opsce crate. +//! +//! All modules in the crate (provider rating, KYC verification, ...) report +//! failures through this single enum so callers and tests have a uniform +//! error surface. + +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + // -- Lifecycle -- + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + + // -- Provider rating -- + /// Rating is outside the allowed 1..=5 range. + InvalidRating = 4, + /// The maintenance record has already been rated. + AlreadyRated = 5, + /// Maintenance record id was not found. + RecordNotFound = 6, + /// Maintenance record exists but is not yet marked complete. + RecordNotComplete = 7, + /// Provider profile is not registered. + ProviderNotFound = 8, + + // -- KYC verification -- + /// The caller is not registered as a KYC oracle. + NotOracle = 20, + /// `submit_kyc_result` was called with a status that is not Approved or + /// Rejected. + InvalidKycStatus = 21, + /// The address has no Approved (or non-expired) KYC record. + KycNotApproved = 22, +} diff --git a/contracts/opsce/src/kyc_verification.rs b/contracts/opsce/src/kyc_verification.rs new file mode 100644 index 00000000..0b58a8ba --- /dev/null +++ b/contracts/opsce/src/kyc_verification.rs @@ -0,0 +1,315 @@ +//! KYC verification module +//! +//! Implements the on-chain KYC workflow: +//! +//! - Whitelisted oracles submit results via `submit_kyc_result`. +//! - Asset-transfer style functions can call `require_kyc` as a guard. +//! - Approval expires after the stored `expiry` ledger timestamp; once expired +//! `get_kyc_status` reports `Expired` and `require_kyc` rejects. +//! +//! The KYC functions are exposed as additional methods on the crate's +//! [`OpsceContract`](crate::provider_rating::OpsceContract) via a second +//! `#[contractimpl]` block. + +use soroban_sdk::{contractimpl, contracttype, Address, Env, Symbol}; + +use crate::error::ContractError; +use crate::provider_rating::{read_admin, OpsceContract}; + +/// Lifecycle of a KYC record. +/// +/// `Expired` is a derived state surfaced by `get_kyc_status` when an +/// `Approved` record's expiry has passed; it cannot be submitted directly. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum KycStatus { + Pending, + Approved, + Rejected, + Expired, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KycRecord { + pub address: Address, + pub status: KycStatus, + /// Ledger timestamp after which the KYC approval is no longer valid. + pub expiry: u64, + pub updated_at: u64, +} + +/// Read model returned by `get_kyc_status`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KycStatusInfo { + pub status: KycStatus, + pub expiry: u64, +} + +#[contracttype] +pub enum KycDataKey { + /// Stores `bool` true when an address is whitelisted as a KYC oracle. + Oracle(Address), + /// Stores the `KycRecord` for an address. + Record(Address), +} + +#[contractimpl] +impl OpsceContract { + /// Whitelist a KYC oracle. Admin only. + pub fn add_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(&env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &true); + Ok(()) + } + + /// Remove a KYC oracle from the whitelist. Admin only. + pub fn remove_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(&env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &false); + Ok(()) + } + + pub fn is_kyc_oracle(env: Env, oracle: Address) -> bool { + env.storage() + .persistent() + .get(&KycDataKey::Oracle(oracle)) + .unwrap_or(false) + } + + /// Submit the outcome of an off-chain KYC check. + /// + /// `oracle` must be whitelisted via [`add_kyc_oracle`] and must authorize + /// the call. `status` must be either `Approved` or `Rejected`; all other + /// variants return `Err(ContractError::InvalidKycStatus)`. + pub fn submit_kyc_result( + env: Env, + oracle: Address, + address: Address, + status: KycStatus, + expiry: u64, + ) -> Result<(), ContractError> { + oracle.require_auth(); + + // Reject any caller that is not on the oracle whitelist. + let is_oracle: bool = env + .storage() + .persistent() + .get(&KycDataKey::Oracle(oracle.clone())) + .unwrap_or(false); + if !is_oracle { + return Err(ContractError::NotOracle); + } + + // Only Approved and Rejected may be submitted. + match status { + KycStatus::Approved | KycStatus::Rejected => {} + _ => return Err(ContractError::InvalidKycStatus), + } + + let record = KycRecord { + address: address.clone(), + status: status.clone(), + expiry, + updated_at: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&KycDataKey::Record(address.clone()), &record); + + // Emit the appropriate event. + match status { + KycStatus::Approved => { + env.events() + .publish((Symbol::new(&env, "kyc_approved"), address), expiry); + } + KycStatus::Rejected => { + env.events() + .publish((Symbol::new(&env, "kyc_rejected"), address), expiry); + } + _ => {} + } + + Ok(()) + } + + /// Guard helper for transfer-style functions. + /// + /// Returns `Ok(())` only when the address has an `Approved` KYC record + /// whose expiry timestamp is still in the future. Otherwise returns + /// `Err(ContractError::KycNotApproved)`. + pub fn require_kyc(env: Env, address: Address) -> Result<(), ContractError> { + let record: KycRecord = env + .storage() + .persistent() + .get(&KycDataKey::Record(address)) + .ok_or(ContractError::KycNotApproved)?; + + match record.status { + KycStatus::Approved => { + if env.ledger().timestamp() > record.expiry { + Err(ContractError::KycNotApproved) + } else { + Ok(()) + } + } + _ => Err(ContractError::KycNotApproved), + } + } + + /// Returns the current effective status and expiry timestamp for an + /// address. An approved record whose expiry has passed is reported as + /// `Expired`. Unknown addresses return `Pending` with expiry `0`. + pub fn get_kyc_status(env: Env, address: Address) -> KycStatusInfo { + match env + .storage() + .persistent() + .get::<_, KycRecord>(&KycDataKey::Record(address)) + { + Some(record) => { + let now = env.ledger().timestamp(); + let status = if matches!(record.status, KycStatus::Approved) && now > record.expiry + { + KycStatus::Expired + } else { + record.status + }; + KycStatusInfo { + status, + expiry: record.expiry, + } + } + None => KycStatusInfo { + status: KycStatus::Pending, + expiry: 0, + }, + } + } +} + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use crate::provider_rating::{OpsceContract, OpsceContractClient}; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::{Address, Env}; + + struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + oracle: Address, + user: Address, + } + + fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + client.add_kyc_oracle(&oracle); + + Fixture { + env, + client, + oracle, + user, + } + } + + #[test] + fn oracle_can_submit_approval_and_status_is_visible() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 1_000; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Status reflects the approval with the stored expiry. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Approved); + assert_eq!(info.expiry, expiry); + + // require_kyc is now satisfied. + fx.client.require_kyc(&fx.user); + } + + #[test] + fn approval_expires_after_stored_timestamp() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 100; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Move ledger time past the expiry. + fx.env.ledger().with_mut(|l| { + l.timestamp = expiry + 1; + }); + + // get_kyc_status surfaces Expired. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Expired); + assert_eq!(info.expiry, expiry); + + // require_kyc rejects an expired approval. + let result = fx.client.try_require_kyc(&fx.user); + assert!(result.is_err()); + } + + #[test] + fn non_oracle_caller_is_rejected() { + let fx = setup(); + let imposter = Address::generate(&fx.env); + let expiry = fx.env.ledger().timestamp() + 1_000; + + let result = fx.client.try_submit_kyc_result( + &imposter, + &fx.user, + &KycStatus::Approved, + &expiry, + ); + assert!(result.is_err()); + + // No record was written, so require_kyc still rejects. + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); + + // And status remains Pending. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Pending); + assert_eq!(info.expiry, 0); + } + + #[test] + fn rejected_status_blocks_require_kyc_and_emits_event() { + let fx = setup(); + let expiry = fx.env.ledger().timestamp() + 500; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Rejected, &expiry); + + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Rejected); + + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); + } +} diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index 02a5acb7..9cdae0cd 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -1,9 +1,12 @@ #![no_std] #![allow(clippy::too_many_arguments)] +pub mod error; +pub mod kyc_verification; pub mod provider_rating; +pub use error::ContractError; +pub use kyc_verification::{KycDataKey, KycRecord, KycStatus, KycStatusInfo}; pub use provider_rating::{ - ContractError, MaintenanceRecord, OpsceContract, OpsceContractClient, ProviderProfile, - ProviderRating, Review, + MaintenanceRecord, OpsceContract, OpsceContractClient, ProviderProfile, ProviderRating, Review, }; diff --git a/contracts/opsce/src/provider_rating.rs b/contracts/opsce/src/provider_rating.rs index 4a265ce6..3821a833 100644 --- a/contracts/opsce/src/provider_rating.rs +++ b/contracts/opsce/src/provider_rating.rs @@ -15,28 +15,10 @@ //! - Emits a `provider_rated` event carrying the rating value and record id use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, Address, Env, String, Symbol, + contract, contractimpl, contracttype, Address, Env, String, Symbol, }; -/// Errors returned by this contract. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ContractError { - NotInitialized = 1, - AlreadyInitialized = 2, - Unauthorized = 3, - /// Rating is outside the allowed 1..=5 range. - InvalidRating = 4, - /// The maintenance record was already rated. - AlreadyRated = 5, - /// Maintenance record id was not found. - RecordNotFound = 6, - /// Maintenance record exists but is not yet marked complete. - RecordNotComplete = 7, - /// Provider profile is not registered. - ProviderNotFound = 8, -} +pub use crate::error::ContractError; /// Provider profile with cumulative rating fields. /// @@ -96,6 +78,15 @@ pub enum DataKey { Review(u64), } +/// Internal helper used by sibling modules (e.g. `kyc_verification`) to read +/// the admin address registered through [`OpsceContract::init`]. +pub(crate) fn read_admin(env: &Env) -> Result { + env.storage() + .persistent() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized) +} + #[contract] pub struct OpsceContract; From 6a0cb8de9b99302bd4cbd8760b35a14c8bd23dbc Mon Sep 17 00:00:00 2001 From: ahmadogo Date: Sat, 30 May 2026 11:53:50 +0100 Subject: [PATCH 3/3] Complete KYC identity verification logic --- contracts/opsce/src/kyc_verification.rs | 373 ++++++---------- contracts/opsce/src/lib.rs | 87 +++- contracts/opsce/src/provider_rating.rs | 421 +++++++------------ contracts/opsce/src/tests_kyc.rs | 117 ++++++ contracts/opsce/src/tests_provider_rating.rs | 126 ++++++ 5 files changed, 596 insertions(+), 528 deletions(-) create mode 100644 contracts/opsce/src/tests_kyc.rs create mode 100644 contracts/opsce/src/tests_provider_rating.rs diff --git a/contracts/opsce/src/kyc_verification.rs b/contracts/opsce/src/kyc_verification.rs index 0b58a8ba..7048aea3 100644 --- a/contracts/opsce/src/kyc_verification.rs +++ b/contracts/opsce/src/kyc_verification.rs @@ -2,19 +2,16 @@ //! //! Implements the on-chain KYC workflow: //! -//! - Whitelisted oracles submit results via `submit_kyc_result`. -//! - Asset-transfer style functions can call `require_kyc` as a guard. -//! - Approval expires after the stored `expiry` ledger timestamp; once expired -//! `get_kyc_status` reports `Expired` and `require_kyc` rejects. -//! -//! The KYC functions are exposed as additional methods on the crate's -//! [`OpsceContract`](crate::provider_rating::OpsceContract) via a second -//! `#[contractimpl]` block. +//! - Whitelisted oracles submit results via [`submit_kyc_result`]. +//! - Asset-transfer style functions can call [`require_kyc`] as a guard. +//! - Approval expires after the stored `expiry` ledger timestamp; once +//! expired, [`get_kyc_status`] reports `Expired` and [`require_kyc`] +//! rejects. -use soroban_sdk::{contractimpl, contracttype, Address, Env, Symbol}; +use soroban_sdk::{contracttype, Address, Env, Symbol}; use crate::error::ContractError; -use crate::provider_rating::{read_admin, OpsceContract}; +use crate::provider_rating::read_admin; /// Lifecycle of a KYC record. /// @@ -55,261 +52,137 @@ pub enum KycDataKey { Record(Address), } -#[contractimpl] -impl OpsceContract { - /// Whitelist a KYC oracle. Admin only. - pub fn add_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { - let admin = read_admin(&env)?; - admin.require_auth(); - env.storage() - .persistent() - .set(&KycDataKey::Oracle(oracle), &true); - Ok(()) - } +/// Whitelist a KYC oracle. Admin only. +pub fn add_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &true); + Ok(()) +} - /// Remove a KYC oracle from the whitelist. Admin only. - pub fn remove_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { - let admin = read_admin(&env)?; - admin.require_auth(); - env.storage() - .persistent() - .set(&KycDataKey::Oracle(oracle), &false); - Ok(()) - } +/// Remove a KYC oracle from the whitelist. Admin only. +pub fn remove_kyc_oracle(env: &Env, oracle: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&KycDataKey::Oracle(oracle), &false); + Ok(()) +} - pub fn is_kyc_oracle(env: Env, oracle: Address) -> bool { - env.storage() - .persistent() - .get(&KycDataKey::Oracle(oracle)) - .unwrap_or(false) - } +pub fn is_kyc_oracle(env: &Env, oracle: Address) -> bool { + env.storage() + .persistent() + .get(&KycDataKey::Oracle(oracle)) + .unwrap_or(false) +} - /// Submit the outcome of an off-chain KYC check. - /// - /// `oracle` must be whitelisted via [`add_kyc_oracle`] and must authorize - /// the call. `status` must be either `Approved` or `Rejected`; all other - /// variants return `Err(ContractError::InvalidKycStatus)`. - pub fn submit_kyc_result( - env: Env, - oracle: Address, - address: Address, - status: KycStatus, - expiry: u64, - ) -> Result<(), ContractError> { - oracle.require_auth(); +/// Submit the outcome of an off-chain KYC check. +/// +/// `oracle` must be whitelisted via [`add_kyc_oracle`] and must authorize the +/// call. `status` must be either `Approved` or `Rejected`; all other variants +/// return `Err(ContractError::InvalidKycStatus)`. +pub fn submit_kyc_result( + env: &Env, + oracle: Address, + address: Address, + status: KycStatus, + expiry: u64, +) -> Result<(), ContractError> { + oracle.require_auth(); + + // Reject any caller that is not on the oracle whitelist. + let is_oracle: bool = env + .storage() + .persistent() + .get(&KycDataKey::Oracle(oracle.clone())) + .unwrap_or(false); + if !is_oracle { + return Err(ContractError::NotOracle); + } - // Reject any caller that is not on the oracle whitelist. - let is_oracle: bool = env - .storage() - .persistent() - .get(&KycDataKey::Oracle(oracle.clone())) - .unwrap_or(false); - if !is_oracle { - return Err(ContractError::NotOracle); - } + // Only Approved and Rejected may be submitted. + match status { + KycStatus::Approved | KycStatus::Rejected => {} + _ => return Err(ContractError::InvalidKycStatus), + } - // Only Approved and Rejected may be submitted. - match status { - KycStatus::Approved | KycStatus::Rejected => {} - _ => return Err(ContractError::InvalidKycStatus), + let record = KycRecord { + address: address.clone(), + status: status.clone(), + expiry, + updated_at: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&KycDataKey::Record(address.clone()), &record); + + // Emit the appropriate event. + match status { + KycStatus::Approved => { + env.events() + .publish((Symbol::new(env, "kyc_approved"), address), expiry); } - - let record = KycRecord { - address: address.clone(), - status: status.clone(), - expiry, - updated_at: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&KycDataKey::Record(address.clone()), &record); - - // Emit the appropriate event. - match status { - KycStatus::Approved => { - env.events() - .publish((Symbol::new(&env, "kyc_approved"), address), expiry); - } - KycStatus::Rejected => { - env.events() - .publish((Symbol::new(&env, "kyc_rejected"), address), expiry); - } - _ => {} + KycStatus::Rejected => { + env.events() + .publish((Symbol::new(env, "kyc_rejected"), address), expiry); } - - Ok(()) + _ => {} } - /// Guard helper for transfer-style functions. - /// - /// Returns `Ok(())` only when the address has an `Approved` KYC record - /// whose expiry timestamp is still in the future. Otherwise returns - /// `Err(ContractError::KycNotApproved)`. - pub fn require_kyc(env: Env, address: Address) -> Result<(), ContractError> { - let record: KycRecord = env - .storage() - .persistent() - .get(&KycDataKey::Record(address)) - .ok_or(ContractError::KycNotApproved)?; - - match record.status { - KycStatus::Approved => { - if env.ledger().timestamp() > record.expiry { - Err(ContractError::KycNotApproved) - } else { - Ok(()) - } - } - _ => Err(ContractError::KycNotApproved), - } - } + Ok(()) +} - /// Returns the current effective status and expiry timestamp for an - /// address. An approved record whose expiry has passed is reported as - /// `Expired`. Unknown addresses return `Pending` with expiry `0`. - pub fn get_kyc_status(env: Env, address: Address) -> KycStatusInfo { - match env - .storage() - .persistent() - .get::<_, KycRecord>(&KycDataKey::Record(address)) - { - Some(record) => { - let now = env.ledger().timestamp(); - let status = if matches!(record.status, KycStatus::Approved) && now > record.expiry - { - KycStatus::Expired - } else { - record.status - }; - KycStatusInfo { - status, - expiry: record.expiry, - } +/// Guard helper for transfer-style functions. +/// +/// Returns `Ok(())` only when the address has an `Approved` KYC record whose +/// expiry timestamp is still in the future. Otherwise returns +/// `Err(ContractError::KycNotApproved)`. +pub fn require_kyc(env: &Env, address: Address) -> Result<(), ContractError> { + let record: KycRecord = env + .storage() + .persistent() + .get(&KycDataKey::Record(address)) + .ok_or(ContractError::KycNotApproved)?; + + match record.status { + KycStatus::Approved => { + if env.ledger().timestamp() > record.expiry { + Err(ContractError::KycNotApproved) + } else { + Ok(()) } - None => KycStatusInfo { - status: KycStatus::Pending, - expiry: 0, - }, } + _ => Err(ContractError::KycNotApproved), } } -#[cfg(test)] -mod tests { - extern crate std; - - use super::*; - use crate::provider_rating::{OpsceContract, OpsceContractClient}; - use soroban_sdk::testutils::{Address as _, Ledger}; - use soroban_sdk::{Address, Env}; - - struct Fixture { - env: Env, - client: OpsceContractClient<'static>, - oracle: Address, - user: Address, - } - - fn setup() -> Fixture { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(OpsceContract, ()); - let client = OpsceContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - - client.init(&admin); - client.add_kyc_oracle(&oracle); - - Fixture { - env, - client, - oracle, - user, +/// Returns the current effective status and expiry timestamp for an address. +/// An approved record whose expiry has passed is reported as `Expired`. +/// Unknown addresses return `Pending` with expiry `0`. +pub fn get_kyc_status(env: &Env, address: Address) -> KycStatusInfo { + match env + .storage() + .persistent() + .get::<_, KycRecord>(&KycDataKey::Record(address)) + { + Some(record) => { + let now = env.ledger().timestamp(); + let status = if matches!(record.status, KycStatus::Approved) && now > record.expiry { + KycStatus::Expired + } else { + record.status + }; + KycStatusInfo { + status, + expiry: record.expiry, + } } - } - - #[test] - fn oracle_can_submit_approval_and_status_is_visible() { - let fx = setup(); - let now = fx.env.ledger().timestamp(); - let expiry = now + 1_000; - - fx.client - .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); - - // Status reflects the approval with the stored expiry. - let info = fx.client.get_kyc_status(&fx.user); - assert_eq!(info.status, KycStatus::Approved); - assert_eq!(info.expiry, expiry); - - // require_kyc is now satisfied. - fx.client.require_kyc(&fx.user); - } - - #[test] - fn approval_expires_after_stored_timestamp() { - let fx = setup(); - let now = fx.env.ledger().timestamp(); - let expiry = now + 100; - - fx.client - .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); - - // Move ledger time past the expiry. - fx.env.ledger().with_mut(|l| { - l.timestamp = expiry + 1; - }); - - // get_kyc_status surfaces Expired. - let info = fx.client.get_kyc_status(&fx.user); - assert_eq!(info.status, KycStatus::Expired); - assert_eq!(info.expiry, expiry); - - // require_kyc rejects an expired approval. - let result = fx.client.try_require_kyc(&fx.user); - assert!(result.is_err()); - } - - #[test] - fn non_oracle_caller_is_rejected() { - let fx = setup(); - let imposter = Address::generate(&fx.env); - let expiry = fx.env.ledger().timestamp() + 1_000; - - let result = fx.client.try_submit_kyc_result( - &imposter, - &fx.user, - &KycStatus::Approved, - &expiry, - ); - assert!(result.is_err()); - - // No record was written, so require_kyc still rejects. - let guard = fx.client.try_require_kyc(&fx.user); - assert!(guard.is_err()); - - // And status remains Pending. - let info = fx.client.get_kyc_status(&fx.user); - assert_eq!(info.status, KycStatus::Pending); - assert_eq!(info.expiry, 0); - } - - #[test] - fn rejected_status_blocks_require_kyc_and_emits_event() { - let fx = setup(); - let expiry = fx.env.ledger().timestamp() + 500; - - fx.client - .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Rejected, &expiry); - - let info = fx.client.get_kyc_status(&fx.user); - assert_eq!(info.status, KycStatus::Rejected); - - let guard = fx.client.try_require_kyc(&fx.user); - assert!(guard.is_err()); + None => KycStatusInfo { + status: KycStatus::Pending, + expiry: 0, + }, } } diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index 9cdae0cd..9f41a5d8 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -8,5 +8,90 @@ pub mod provider_rating; pub use error::ContractError; pub use kyc_verification::{KycDataKey, KycRecord, KycStatus, KycStatusInfo}; pub use provider_rating::{ - MaintenanceRecord, OpsceContract, OpsceContractClient, ProviderProfile, ProviderRating, Review, + DataKey, MaintenanceRecord, ProviderProfile, ProviderRating, Review, }; + +use soroban_sdk::{contract, contractimpl, Address, Env, String}; + +#[contract] +pub struct OpsceContract; + +#[contractimpl] +impl OpsceContract { + // ---- Lifecycle ---- + + pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { + provider_rating::init(&env, admin) + } + + // ---- Provider rating ---- + + pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> { + provider_rating::register_provider(&env, provider) + } + + pub fn record_completed_maintenance( + env: Env, + record_id: u64, + asset_id: u64, + owner: Address, + provider: Address, + ) -> Result<(), ContractError> { + provider_rating::record_completed_maintenance(&env, record_id, asset_id, owner, provider) + } + + pub fn rate_provider( + env: Env, + record_id: u64, + rating: u32, + comment: String, + ) -> Result<(), ContractError> { + provider_rating::rate_provider(&env, record_id, rating, comment) + } + + pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating { + provider_rating::get_provider_rating(&env, provider_address) + } + + pub fn get_review(env: Env, record_id: u64) -> Option { + provider_rating::get_review(&env, record_id) + } + + // ---- KYC verification ---- + + pub fn add_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + kyc_verification::add_kyc_oracle(&env, oracle) + } + + pub fn remove_kyc_oracle(env: Env, oracle: Address) -> Result<(), ContractError> { + kyc_verification::remove_kyc_oracle(&env, oracle) + } + + pub fn is_kyc_oracle(env: Env, oracle: Address) -> bool { + kyc_verification::is_kyc_oracle(&env, oracle) + } + + pub fn submit_kyc_result( + env: Env, + oracle: Address, + address: Address, + status: KycStatus, + expiry: u64, + ) -> Result<(), ContractError> { + kyc_verification::submit_kyc_result(&env, oracle, address, status, expiry) + } + + pub fn require_kyc(env: Env, address: Address) -> Result<(), ContractError> { + kyc_verification::require_kyc(&env, address) + } + + pub fn get_kyc_status(env: Env, address: Address) -> KycStatusInfo { + kyc_verification::get_kyc_status(&env, address) + } +} + +#[cfg(test)] +mod tests_provider_rating; + +#[cfg(test)] +mod tests_kyc; diff --git a/contracts/opsce/src/provider_rating.rs b/contracts/opsce/src/provider_rating.rs index 3821a833..86114bce 100644 --- a/contracts/opsce/src/provider_rating.rs +++ b/contracts/opsce/src/provider_rating.rs @@ -14,9 +14,7 @@ //! `{ average_rating, total_reviews }` //! - Emits a `provider_rated` event carrying the rating value and record id -use soroban_sdk::{ - contract, contractimpl, contracttype, Address, Env, String, Symbol, -}; +use soroban_sdk::{contracttype, Address, Env, String, Symbol}; pub use crate::error::ContractError; @@ -78,307 +76,176 @@ pub enum DataKey { Review(u64), } -/// Internal helper used by sibling modules (e.g. `kyc_verification`) to read -/// the admin address registered through [`OpsceContract::init`]. -pub(crate) fn read_admin(env: &Env) -> Result { +/// Read the admin address registered through [`init`]. +pub fn read_admin(env: &Env) -> Result { env.storage() .persistent() .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized) } -#[contract] -pub struct OpsceContract; - -#[contractimpl] -impl OpsceContract { - /// One-time initialization. Stores the admin used to register providers - /// and seed maintenance records. - pub fn init(env: Env, admin: Address) -> Result<(), ContractError> { - if env.storage().persistent().has(&DataKey::Admin) { - return Err(ContractError::AlreadyInitialized); - } - env.storage().persistent().set(&DataKey::Admin, &admin); - Ok(()) - } - - /// Register a new provider with a zeroed rating profile. Admin only. - pub fn register_provider(env: Env, provider: Address) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(ContractError::NotInitialized)?; - admin.require_auth(); - - let profile = ProviderProfile { - address: provider.clone(), - total_reviews: 0, - rating_sum: 0, - average_rating: 0, - }; - env.storage() - .persistent() - .set(&DataKey::Provider(provider), &profile); - Ok(()) +/// One-time initialization. Stores the admin used to register providers and +/// seed maintenance records. +pub fn init(env: &Env, admin: Address) -> Result<(), ContractError> { + if env.storage().persistent().has(&DataKey::Admin) { + return Err(ContractError::AlreadyInitialized); } + env.storage().persistent().set(&DataKey::Admin, &admin); + Ok(()) +} - /// Persist a maintenance record (already completed). Admin only. - /// In production this would be invoked by the asset-maintenance contract - /// when a record is marked complete; here it acts as the entry point that - /// makes a record eligible for rating. - pub fn record_completed_maintenance( - env: Env, - record_id: u64, - asset_id: u64, - owner: Address, - provider: Address, - ) -> Result<(), ContractError> { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(ContractError::NotInitialized)?; - admin.require_auth(); - - if !env - .storage() - .persistent() - .has(&DataKey::Provider(provider.clone())) - { - return Err(ContractError::ProviderNotFound); - } - - let record = MaintenanceRecord { - record_id, - asset_id, - provider, - owner, - completed: true, - }; - env.storage() - .persistent() - .set(&DataKey::Record(record_id), &record); - Ok(()) - } - - /// Rate a provider on a completed maintenance record. - /// - /// Authorization: the asset owner stored on the record must authorize the - /// call (`require_auth`). Rating must be in the inclusive range 1..=5 and - /// each record can be rated only once. - pub fn rate_provider( - env: Env, - record_id: u64, - rating: u32, - comment: String, - ) -> Result<(), ContractError> { - // Validate rating bounds first so 0 and 6+ are rejected before any - // storage access. - if rating < 1 || rating > 5 { - return Err(ContractError::InvalidRating); - } - - let record: MaintenanceRecord = env - .storage() - .persistent() - .get(&DataKey::Record(record_id)) - .ok_or(ContractError::RecordNotFound)?; - - if !record.completed { - return Err(ContractError::RecordNotComplete); - } - - // Caller must be the asset owner stored on the record. - record.owner.require_auth(); - - if env - .storage() - .persistent() - .has(&DataKey::RatedRecord(record_id)) - { - return Err(ContractError::AlreadyRated); - } - - let mut profile: ProviderProfile = env - .storage() - .persistent() - .get(&DataKey::Provider(record.provider.clone())) - .ok_or(ContractError::ProviderNotFound)?; - - // Update the running totals and average (scaled by 100). - profile.rating_sum = profile.rating_sum.saturating_add(rating); - profile.total_reviews = profile.total_reviews.saturating_add(1); - profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews; - - env.storage() - .persistent() - .set(&DataKey::Provider(record.provider.clone()), &profile); - - // Mark this record as rated and persist the review entry. - env.storage() - .persistent() - .set(&DataKey::RatedRecord(record_id), &true); - - let review = Review { - record_id, - provider: record.provider.clone(), - rater: record.owner.clone(), - rating, - comment, - timestamp: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&DataKey::Review(record_id), &review); - - // Emit the `provider_rated` event with the rating value and record id. - let topic = Symbol::new(&env, "provider_rated"); - env.events() - .publish((topic, record.provider), (rating, record_id)); - - Ok(()) - } +/// Register a new provider with a zeroed rating profile. Admin only. +pub fn register_provider(env: &Env, provider: Address) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + + let profile = ProviderProfile { + address: provider.clone(), + total_reviews: 0, + rating_sum: 0, + average_rating: 0, + }; + env.storage() + .persistent() + .set(&DataKey::Provider(provider), &profile); + Ok(()) +} - /// Returns the current `{ average_rating, total_reviews }` for a provider. - /// Unknown providers return zero values rather than an error so callers - /// can use this as a cheap query. - pub fn get_provider_rating(env: Env, provider_address: Address) -> ProviderRating { - match env - .storage() - .persistent() - .get::<_, ProviderProfile>(&DataKey::Provider(provider_address)) - { - Some(p) => ProviderRating { - average_rating: p.average_rating, - total_reviews: p.total_reviews, - }, - None => ProviderRating { - average_rating: 0, - total_reviews: 0, - }, - } +/// Persist a maintenance record (already completed). Admin only. +/// +/// In production this would be invoked by the asset-maintenance contract when +/// a record is marked complete; here it acts as the entry point that makes a +/// record eligible for rating. +pub fn record_completed_maintenance( + env: &Env, + record_id: u64, + asset_id: u64, + owner: Address, + provider: Address, +) -> Result<(), ContractError> { + let admin = read_admin(env)?; + admin.require_auth(); + + if !env + .storage() + .persistent() + .has(&DataKey::Provider(provider.clone())) + { + return Err(ContractError::ProviderNotFound); } - /// Fetch the persisted review for a record, if any. - pub fn get_review(env: Env, record_id: u64) -> Option { - env.storage().persistent().get(&DataKey::Review(record_id)) - } + let record = MaintenanceRecord { + record_id, + asset_id, + provider, + owner, + completed: true, + }; + env.storage() + .persistent() + .set(&DataKey::Record(record_id), &record); + Ok(()) } -#[cfg(test)] -mod tests { - extern crate std; - - use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::{Address, Env, String}; - - struct Fixture { - env: Env, - client: OpsceContractClient<'static>, - owner: Address, - provider: Address, - record_id: u64, +/// Rate a provider on a completed maintenance record. +/// +/// Authorization: the asset owner stored on the record must authorize the +/// call (`require_auth`). Rating must be in the inclusive range 1..=5 and each +/// record can be rated only once. +pub fn rate_provider( + env: &Env, + record_id: u64, + rating: u32, + comment: String, +) -> Result<(), ContractError> { + // Validate rating bounds first so 0 and 6+ are rejected before any + // storage access. + if rating < 1 || rating > 5 { + return Err(ContractError::InvalidRating); } - fn setup() -> Fixture { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(OpsceContract, ()); - let client = OpsceContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let provider = Address::generate(&env); - let owner = Address::generate(&env); - - client.init(&admin); - client.register_provider(&provider); - - let record_id: u64 = 1; - client.record_completed_maintenance(&record_id, &101u64, &owner, &provider); + let record: MaintenanceRecord = env + .storage() + .persistent() + .get(&DataKey::Record(record_id)) + .ok_or(ContractError::RecordNotFound)?; - Fixture { - env, - client, - owner, - provider, - record_id, - } + if !record.completed { + return Err(ContractError::RecordNotComplete); } - #[test] - fn rate_provider_valid_rating_updates_running_average() { - let fx = setup(); - - // First rating: 5 stars. - fx.client - .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); - - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 1); - assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100 - - // Second rating on a different record: 4 stars => running avg 4.5. - let record_id2: u64 = 2; - fx.client - .record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider); - fx.client - .rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good")); + // Caller must be the asset owner stored on the record. + record.owner.require_auth(); - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 2); - assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450 + if env + .storage() + .persistent() + .has(&DataKey::RatedRecord(record_id)) + { + return Err(ContractError::AlreadyRated); } - #[test] - fn rate_provider_duplicate_returns_already_rated() { - let fx = setup(); + let mut profile: ProviderProfile = env + .storage() + .persistent() + .get(&DataKey::Provider(record.provider.clone())) + .ok_or(ContractError::ProviderNotFound)?; - fx.client - .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + // Update the running totals and average (scaled by 100). + profile.rating_sum = profile.rating_sum.saturating_add(rating); + profile.total_reviews = profile.total_reviews.saturating_add(1); + profile.average_rating = (profile.rating_sum.saturating_mul(100)) / profile.total_reviews; - // try_ returns the error variant without panicking on contract errors. - let result = fx.client.try_rate_provider( - &fx.record_id, - &4u32, - &String::from_str(&fx.env, "Again"), - ); - assert_eq!(result, Err(Ok(ContractError::AlreadyRated))); + env.storage() + .persistent() + .set(&DataKey::Provider(record.provider.clone()), &profile); - // The provider's average must remain at the first rating only. - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 1); - assert_eq!(rating.average_rating, 500); - } + // Mark this record as rated and persist the review entry. + env.storage() + .persistent() + .set(&DataKey::RatedRecord(record_id), &true); + + let review = Review { + record_id, + provider: record.provider.clone(), + rater: record.owner.clone(), + rating, + comment, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::Review(record_id), &review); - #[test] - fn rate_provider_rejects_rating_below_one() { - let fx = setup(); + // Emit the `provider_rated` event with the rating value and record id. + let topic = Symbol::new(env, "provider_rated"); + env.events() + .publish((topic, record.provider), (rating, record_id)); - let result = fx - .client - .try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, "")); - assert_eq!(result, Err(Ok(ContractError::InvalidRating))); + Ok(()) +} - // No state change occurred. - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 0); - assert_eq!(rating.average_rating, 0); +/// Returns the current `{ average_rating, total_reviews }` for a provider. +/// Unknown providers return zero values rather than an error so callers can +/// use this as a cheap query. +pub fn get_provider_rating(env: &Env, provider_address: Address) -> ProviderRating { + match env + .storage() + .persistent() + .get::<_, ProviderProfile>(&DataKey::Provider(provider_address)) + { + Some(p) => ProviderRating { + average_rating: p.average_rating, + total_reviews: p.total_reviews, + }, + None => ProviderRating { + average_rating: 0, + total_reviews: 0, + }, } +} - #[test] - fn rate_provider_rejects_rating_above_five() { - let fx = setup(); - - let result = fx - .client - .try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, "")); - assert_eq!(result, Err(Ok(ContractError::InvalidRating))); - - let rating = fx.client.get_provider_rating(&fx.provider); - assert_eq!(rating.total_reviews, 0); - assert_eq!(rating.average_rating, 0); - } +/// Fetch the persisted review for a record, if any. +pub fn get_review(env: &Env, record_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Review(record_id)) } diff --git a/contracts/opsce/src/tests_kyc.rs b/contracts/opsce/src/tests_kyc.rs new file mode 100644 index 00000000..3aee9830 --- /dev/null +++ b/contracts/opsce/src/tests_kyc.rs @@ -0,0 +1,117 @@ +#![cfg(test)] +extern crate std; + +use crate::kyc_verification::KycStatus; +use crate::{OpsceContract, OpsceContractClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{Address, Env}; + +struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + oracle: Address, + user: Address, +} + +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + + client.init(&admin); + client.add_kyc_oracle(&oracle); + + Fixture { + env, + client, + oracle, + user, + } +} + +#[test] +fn oracle_can_submit_approval_and_status_is_visible() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 1_000; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Status reflects the approval with the stored expiry. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Approved); + assert_eq!(info.expiry, expiry); + + // require_kyc is now satisfied. + fx.client.require_kyc(&fx.user); +} + +#[test] +fn approval_expires_after_stored_timestamp() { + let fx = setup(); + let now = fx.env.ledger().timestamp(); + let expiry = now + 100; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Approved, &expiry); + + // Move ledger time past the expiry. + fx.env.ledger().with_mut(|l| { + l.timestamp = expiry + 1; + }); + + // get_kyc_status surfaces Expired. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Expired); + assert_eq!(info.expiry, expiry); + + // require_kyc rejects an expired approval. + let result = fx.client.try_require_kyc(&fx.user); + assert!(result.is_err(), "expired KYC should be rejected"); +} + +#[test] +fn non_oracle_caller_is_rejected() { + let fx = setup(); + let imposter = Address::generate(&fx.env); + let expiry = fx.env.ledger().timestamp() + 1_000; + + let result = fx.client.try_submit_kyc_result( + &imposter, + &fx.user, + &KycStatus::Approved, + &expiry, + ); + assert!(result.is_err(), "non-oracle submission must be rejected"); + + // No record was written, so require_kyc still rejects. + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); + + // And status remains Pending. + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Pending); + assert_eq!(info.expiry, 0); +} + +#[test] +fn rejected_status_blocks_require_kyc() { + let fx = setup(); + let expiry = fx.env.ledger().timestamp() + 500; + + fx.client + .submit_kyc_result(&fx.oracle, &fx.user, &KycStatus::Rejected, &expiry); + + let info = fx.client.get_kyc_status(&fx.user); + assert_eq!(info.status, KycStatus::Rejected); + + let guard = fx.client.try_require_kyc(&fx.user); + assert!(guard.is_err()); +} diff --git a/contracts/opsce/src/tests_provider_rating.rs b/contracts/opsce/src/tests_provider_rating.rs new file mode 100644 index 00000000..3e516cb6 --- /dev/null +++ b/contracts/opsce/src/tests_provider_rating.rs @@ -0,0 +1,126 @@ +#![cfg(test)] +extern crate std; + +use crate::{ContractError, OpsceContract, OpsceContractClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String}; + +struct Fixture { + env: Env, + client: OpsceContractClient<'static>, + owner: Address, + provider: Address, + record_id: u64, +} + +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(OpsceContract, ()); + let client = OpsceContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let provider = Address::generate(&env); + let owner = Address::generate(&env); + + client.init(&admin); + client.register_provider(&provider); + + let record_id: u64 = 1; + client.record_completed_maintenance(&record_id, &101u64, &owner, &provider); + + Fixture { + env, + client, + owner, + provider, + record_id, + } +} + +#[test] +fn rate_provider_valid_rating_updates_running_average() { + let fx = setup(); + + // First rating: 5 stars. + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); // 5.00 stars scaled by 100 + + // Second rating on a different record: 4 stars => running avg 4.5. + let record_id2: u64 = 2; + fx.client + .record_completed_maintenance(&record_id2, &102u64, &fx.owner, &fx.provider); + fx.client + .rate_provider(&record_id2, &4u32, &String::from_str(&fx.env, "Good")); + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 2); + assert_eq!(rating.average_rating, 450); // (5+4)*100/2 = 450 +} + +#[test] +fn rate_provider_duplicate_returns_already_rated() { + let fx = setup(); + + fx.client + .rate_provider(&fx.record_id, &5u32, &String::from_str(&fx.env, "Great")); + + // Re-rating the same record must fail with AlreadyRated. + let result = fx.client.try_rate_provider( + &fx.record_id, + &4u32, + &String::from_str(&fx.env, "Again"), + ); + assert!(result.is_err(), "duplicate rating should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::AlreadyRated as u32); + assert_eq!(err, expected); + } + + // The provider's average must remain at the first rating only. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 1); + assert_eq!(rating.average_rating, 500); +} + +#[test] +fn rate_provider_rejects_rating_below_one() { + let fx = setup(); + + let result = + fx.client + .try_rate_provider(&fx.record_id, &0u32, &String::from_str(&fx.env, "")); + assert!(result.is_err(), "rating 0 should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::InvalidRating as u32); + assert_eq!(err, expected); + } + + // No state change occurred. + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); +} + +#[test] +fn rate_provider_rejects_rating_above_five() { + let fx = setup(); + + let result = + fx.client + .try_rate_provider(&fx.record_id, &6u32, &String::from_str(&fx.env, "")); + assert!(result.is_err(), "rating 6 should fail"); + if let Err(Ok(err)) = result { + let expected = soroban_sdk::Error::from_contract_error(ContractError::InvalidRating as u32); + assert_eq!(err, expected); + } + + let rating = fx.client.get_provider_rating(&fx.provider); + assert_eq!(rating.total_reviews, 0); + assert_eq!(rating.average_rating, 0); +}