diff --git a/dongle-smartcontract/src/admin_manager.rs b/dongle-smartcontract/src/admin_manager.rs index d0f53b1..8ebd30b 100644 --- a/dongle-smartcontract/src/admin_manager.rs +++ b/dongle-smartcontract/src/admin_manager.rs @@ -12,7 +12,7 @@ use crate::storage_manager::StorageManager; use crate::types::{ AdminActionType, AdminProposal, FeeConfig, ProposalPayload, ProposalStatus, VerificationStatus, }; -use soroban_sdk::{xdr::ToXdr, Address, Env, String, Vec}; +use soroban_sdk::{xdr::ToXdr, Address, Env, Vec}; pub struct AdminManager; impl AdminManager { @@ -23,21 +23,6 @@ impl AdminManager { panic!("Contract already initialized"); } - let zero_account = Address::from_string(&String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - let zero_contract = Address::from_string(&String::from_str( - env, - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", - )); - if admin == zero_account { - panic!("admin cannot be zero address"); - } - if admin == zero_contract { - panic!("admin cannot be zero address"); - } - // Don't require auth during initialization - this is typically called once during contract deployment // Set the admin in storage diff --git a/dongle-smartcontract/src/collection_registry.rs b/dongle-smartcontract/src/collection_registry.rs index 58cf04d..2eda5b2 100644 --- a/dongle-smartcontract/src/collection_registry.rs +++ b/dongle-smartcontract/src/collection_registry.rs @@ -1,210 +1,431 @@ -//! Collection registry – project lifecycle and listing. - -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; - -use crate::constants::*; +use crate::admin_action_log::AdminActionLog; +use crate::auth::require_admin_auth; +use crate::constants::{ + MAX_COLLECTIONS, MAX_COLLECTION_DESCRIPTION_LEN, MAX_COLLECTION_NAME_LEN, + MAX_PROJECTS_PER_COLLECTION, +}; use crate::errors::ContractError; -use crate::storage_keys::StorageKey; -use crate::types::{ - Project, ProjectRegistrationParams, ProjectUpdateParams, Review, ReviewParams, SlugIndexKey, +use crate::events::{ + publish_collection_created_event, publish_collection_deleted_event, + publish_collection_updated_event, publish_project_added_to_collection_event, + publish_project_removed_from_collection_event, }; -use crate::utils; +use crate::storage_keys::StorageKey; +use crate::types::{AdminActionType, Collection}; +use soroban_sdk::{Address, Env, String, Vec}; -#[contract] pub struct CollectionRegistry; -#[contractimpl] impl CollectionRegistry { - pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { - if utils::is_initialized(&env) { - return Err(ContractError::AlreadyInitialized); + pub fn create_collection( + env: &Env, + admin: Address, + name: String, + description: String, + ) -> Result { + require_admin_auth(env, &admin)?; + + Self::validate_name(&name)?; + Self::validate_description(&description)?; + Self::ensure_name_unique(env, &name, None)?; + + let total = Self::get_collection_count(env); + if total >= MAX_COLLECTIONS.into() { + return Err(ContractError::MaxProjectsExceeded); } - env.storage().instance().set(&StorageKey::Admin, &admin); - env.storage().instance().set(&StorageKey::NextProjectId, &1u64); - env.storage().instance().set(&StorageKey::Initialized, &true); - Ok(()) + + let id = Self::get_next_id(env); + let timestamp = env.ledger().timestamp(); + let collection = Collection { + id, + name: name.clone(), + description, + created_at: timestamp, + updated_at: timestamp, + }; + + env.storage() + .persistent() + .set(&StorageKey::Collection(id), &collection); + env.storage() + .persistent() + .set(&StorageKey::CollectionNameById(id), &name); + env.storage() + .persistent() + .set(&StorageKey::CollectionProjectIds(id), &Vec::::new(env)); + + let mut list: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionList) + .unwrap_or(Vec::new(env)); + list.push_back(id); + env.storage() + .persistent() + .set(&StorageKey::CollectionList, &list); + + env.storage() + .persistent() + .set(&StorageKey::NextCollectionId, &(id + 1)); + + publish_collection_created_event(env, id, name, admin.clone()); + + AdminActionLog::record_action( + env, + admin, + AdminActionType::CollectionCreated, + Some(id), + None, + None, + ); + + Ok(id) } - pub fn register_project( - env: Env, - params: ProjectRegistrationParams, - ) -> Result { - // Validate fields - if !utils::is_valid_slug(¶ms.slug) { - return Err(ContractError::InvalidSlug); - } - if params.name.len() == 0 || params.name.len() > MAX_NAME_LENGTH as u32 { - return Err(ContractError::InvalidName); - } - if params.description.len() == 0 || params.description.len() > MAX_DESCRIPTION_LENGTH as u32 { - return Err(ContractError::InvalidDescription); - } - if !utils::is_valid_category(¶ms.category) { - return Err(ContractError::InvalidCategory); + pub fn update_collection( + env: &Env, + admin: Address, + collection_id: u64, + name: String, + description: String, + ) -> Result<(), ContractError> { + require_admin_auth(env, &admin)?; + + let mut collection = Self::require_collection(env, collection_id)?; + + Self::validate_name(&name)?; + Self::validate_description(&description)?; + + if collection.name != name { + Self::ensure_name_unique(env, &name, Some(collection_id))?; } - // Validate optional bounty URL/CID - if let Some(ref url) = params.bounty_url { - if !url.starts_with("http://") && !url.starts_with("https://") { - return Err(ContractError::InvalidBountyUrl); - } - // If it looks like a URL, validate further (basic length check) - if url.len() < 10 { - return Err(ContractError::InvalidBountyUrl); + collection.name = name; + collection.description = description; + collection.updated_at = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&StorageKey::Collection(collection_id), &collection); + env.storage().persistent().set( + &StorageKey::CollectionNameById(collection_id), + &collection.name, + ); + + publish_collection_updated_event(env, collection_id, admin.clone()); + + AdminActionLog::record_action( + env, + admin, + AdminActionType::CollectionUpdated, + Some(collection_id), + None, + None, + ); + + Ok(()) + } + + pub fn delete_collection( + env: &Env, + admin: Address, + collection_id: u64, + ) -> Result<(), ContractError> { + require_admin_auth(env, &admin)?; + + Self::require_collection(env, collection_id)?; + + let list: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionList) + .unwrap_or(Vec::new(env)); + let mut updated = Vec::new(env); + for id in list.iter() { + if id != collection_id { + updated.push_back(id); } } + env.storage() + .persistent() + .set(&StorageKey::CollectionList, &updated); - // Check slug uniqueness - let slug_key = SlugIndexKey { slug: params.slug.clone() }; - if env.storage().persistent().has(&slug_key) { - return Err(ContractError::SlugAlreadyExists); - } + env.storage() + .persistent() + .remove(&StorageKey::Collection(collection_id)); + env.storage() + .persistent() + .remove(&StorageKey::CollectionNameById(collection_id)); + env.storage() + .persistent() + .remove(&StorageKey::CollectionProjectIds(collection_id)); + + publish_collection_deleted_event(env, collection_id, admin.clone()); - // Check owner projects count limit - let owner = params.owner.clone(); - let owner_projects_key = StorageKey::OwnerProjects(owner.clone()); - let mut owner_projects: Vec = env + AdminActionLog::record_action( + env, + admin, + AdminActionType::CollectionDeleted, + Some(collection_id), + None, + None, + ); + + Ok(()) + } + + pub fn add_project_to_collection( + env: &Env, + admin: Address, + collection_id: u64, + project_id: u64, + ) -> Result<(), ContractError> { + require_admin_auth(env, &admin)?; + + Self::require_collection(env, collection_id)?; + + if !env .storage() .persistent() - .get(&owner_projects_key) - .unwrap_or(Vec::new(&env)); - if owner_projects.len() >= MAX_PROJECTS_PER_USER as u32 { - return Err(ContractError::MaxProjectsExceeded); + .has(&StorageKey::Project(project_id)) + { + return Err(ContractError::ProjectNotFound); } - let project_id = env + let mut project_ids: Vec = env .storage() - .instance() - .get::<_, u64>(&StorageKey::NextProjectId) - .unwrap(); - - let project = Project { - id: project_id, - owner: owner.clone(), - name: params.name, - slug: params.slug, - description: params.description, - category: params.category, - website: params.website, - license: params.license, - logo_cid: params.logo_cid, - metadata_cid: params.metadata_cid, - tags: params.tags, - social_links: params.social_links, - launch_timestamp: params.launch_timestamp, - bounty_url: params.bounty_url, - maintainers: None, - archived: false, - created_at: env.ledger().timestamp(), - updated_at: env.ledger().timestamp(), - verification_status: crate::types::VerificationStatus::Unverified, - verified_at: None, - }; + .persistent() + .get(&StorageKey::CollectionProjectIds(collection_id)) + .unwrap_or(Vec::new(env)); - // Store project - let project_key = StorageKey::Project(project_id); - env.storage().persistent().set(&project_key, &project); + if project_ids.iter().any(|id| id == project_id) { + return Err(ContractError::AlreadyInCollection); + } - // Update indexes - owner_projects.push_back(project_id); - env.storage() - .persistent() - .set(&owner_projects_key, &owner_projects); + if project_ids.len() >= MAX_PROJECTS_PER_COLLECTION { + return Err(ContractError::TooManyTags); + } - // Store slug index - env.storage() - .persistent() - .set(&slug_key, &project_id); + project_ids.push_back(project_id); + env.storage().persistent().set( + &StorageKey::CollectionProjectIds(collection_id), + &project_ids, + ); - // Increment next ID - env.storage() - .instance() - .set(&StorageKey::NextProjectId, &(project_id + 1)); + publish_project_added_to_collection_event(env, collection_id, project_id, admin.clone()); - // Emit event (not shown for brevity) + AdminActionLog::record_action( + env, + admin, + AdminActionType::ProjectAddedToCollection, + Some(collection_id), + None, + None, + ); - Ok(project_id) + Ok(()) } - pub fn update_project( - env: Env, + pub fn remove_project_from_collection( + env: &Env, + admin: Address, + collection_id: u64, project_id: u64, - caller: Address, - params: ProjectUpdateParams, ) -> Result<(), ContractError> { - let project_key = StorageKey::Project(project_id); - let mut project: Project = env + require_admin_auth(env, &admin)?; + + Self::require_collection(env, collection_id)?; + + let project_ids: Vec = env .storage() .persistent() - .get(&project_key) - .ok_or(ContractError::ProjectNotFound)?; + .get(&StorageKey::CollectionProjectIds(collection_id)) + .unwrap_or(Vec::new(env)); - // Authorization check - if project.owner != caller && !utils::is_maintainer(&env, &project, &caller) { - return Err(ContractError::NotOwner); + if !project_ids.iter().any(|id| id == project_id) { + return Err(ContractError::AlreadyInCollection); } - // Update optional fields - if let Some(ref name) = params.name { - if name.len() == 0 || name.len() > MAX_NAME_LENGTH as u32 { - return Err(ContractError::InvalidName); + let mut updated = Vec::new(env); + for id in project_ids.iter() { + if id != project_id { + updated.push_back(id); } - project.name = name.clone(); } - if let Some(ref description) = params.description { - if description.len() == 0 || description.len() > MAX_DESCRIPTION_LENGTH as u32 { - return Err(ContractError::InvalidDescription); + env.storage() + .persistent() + .set(&StorageKey::CollectionProjectIds(collection_id), &updated); + + publish_project_removed_from_collection_event( + env, + collection_id, + project_id, + admin.clone(), + ); + + AdminActionLog::record_action( + env, + admin, + AdminActionType::ProjectRemovedFromCollection, + Some(collection_id), + None, + None, + ); + + Ok(()) + } + + pub fn get_collection(env: &Env, collection_id: u64) -> Result { + env.storage() + .persistent() + .get(&StorageKey::Collection(collection_id)) + .ok_or(ContractError::CollectionNotFound) + } + + pub fn list_collections(env: &Env, start: u32, limit: u32) -> Vec { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionList) + .unwrap_or(Vec::new(env)); + + let limit = limit.min(100); + let mut result = Vec::new(env); + let mut count = 0u32; + + for (i, collection_id) in ids.iter().enumerate() { + if (i as u32) < start { + continue; } - project.description = description.clone(); - } - if let Some(ref category) = params.category { - if !utils::is_valid_category(category) { - return Err(ContractError::InvalidCategory); + if count >= limit { + break; + } + if let Some(collection) = env + .storage() + .persistent() + .get::<_, Collection>(&StorageKey::Collection(collection_id)) + { + result.push_back(collection); + count += 1; } - project.category = category.clone(); - } - if let Some(website) = params.website { - project.website = website; - } - if let Some(license) = params.license { - project.license = license; } - if let Some(logo_cid) = params.logo_cid { - project.logo_cid = logo_cid; + + result + } + + pub fn list_collection_projects( + env: &Env, + collection_id: u64, + start: u32, + limit: u32, + ) -> Vec { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionProjectIds(collection_id)) + .unwrap_or(Vec::new(env)); + + let limit = limit.min(100); + let mut result = Vec::new(env); + let mut count = 0u32; + + for (i, project_id) in ids.iter().enumerate() { + if (i as u32) < start { + continue; + } + if count >= limit { + break; + } + result.push_back(project_id); + count += 1; } - if let Some(metadata_cid) = params.metadata_cid { - project.metadata_cid = metadata_cid; + + result + } + + pub fn get_collection_project_count(env: &Env, collection_id: u64) -> u32 { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionProjectIds(collection_id)) + .unwrap_or(Vec::new(env)); + ids.len() + } + + pub fn get_collection_count(env: &Env) -> u64 { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionList) + .unwrap_or(Vec::new(env)); + ids.len().into() + } + + // ── Internal Helpers ────────────────────────────────────────────────── + + fn validate_name(name: &String) -> Result<(), ContractError> { + let len = name.len(); + if len == 0 { + return Err(ContractError::InvalidProjectData); } - if let Some(tags) = params.tags { - project.tags = tags; + if len as usize > MAX_COLLECTION_NAME_LEN { + return Err(ContractError::ProjectNameTooLong); } - if let Some(social_links) = params.social_links { - project.social_links = social_links; + Ok(()) + } + + fn validate_description(description: &String) -> Result<(), ContractError> { + let len = description.len(); + if len == 0 { + return Err(ContractError::InvalidProjectData); } - if let Some(launch_timestamp) = params.launch_timestamp { - project.launch_timestamp = launch_timestamp; + if len as usize > MAX_COLLECTION_DESCRIPTION_LEN { + return Err(ContractError::ProjectDescTooLong); } + Ok(()) + } - // Update bounty_url with validation - if let Some(ref bounty_url) = params.bounty_url { - // Validate URL or CID - if !bounty_url.starts_with("http://") && !bounty_url.starts_with("https://") { - return Err(ContractError::InvalidBountyUrl); + fn ensure_name_unique( + env: &Env, + name: &String, + exclude_id: Option, + ) -> Result<(), ContractError> { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::CollectionList) + .unwrap_or(Vec::new(env)); + + for id in ids.iter() { + if let Some(exclude) = exclude_id { + if id == exclude { + continue; + } } - if bounty_url.len() < 10 { - return Err(ContractError::InvalidBountyUrl); + if let Some(existing_name) = env + .storage() + .persistent() + .get::<_, String>(&StorageKey::CollectionNameById(id)) + { + if existing_name == *name { + return Err(ContractError::CollectionExists); + } } - project.bounty_url = Some(bounty_url.clone()); - } else if params.bounty_url_clear { - // Clear existing bounty if a clear flag is set (could be omitted) - project.bounty_url = None; } - - project.updated_at = env.ledger().timestamp(); - - env.storage().persistent().set(&project_key, &project); Ok(()) } - // Other methods omitted... + fn require_collection(env: &Env, collection_id: u64) -> Result { + env.storage() + .persistent() + .get(&StorageKey::Collection(collection_id)) + .ok_or(ContractError::CollectionNotFound) + } + + fn get_next_id(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&StorageKey::NextCollectionId) + .unwrap_or(1u64) + } } diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index 2b917fa..601fdf8 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -51,6 +51,15 @@ pub const MAX_SECURITY_CONTACT_LEN: usize = 256; #[allow(dead_code)] pub const MAX_CID_LEN: usize = 128; +/// Maximum stored edit revisions per review (oldest dropped when exceeded). +pub const MAX_REVIEW_REVISIONS: u32 = 50; + +/// Bayesian prior review count for weighted rating (see RatingCalculator::calculate_weighted). +pub const WEIGHTED_RATING_PRIOR_COUNT: u32 = 5; + +/// Bayesian prior mean rating scaled by 100 (350 = 3.50 stars). +pub const WEIGHTED_RATING_PRIOR_MEAN: u32 = 350; + /// Project metadata fields whose changes invalidate an existing verification. pub const MAJOR_METADATA_FIELD_NAME: &str = "name"; pub const MAJOR_METADATA_FIELD_WEBSITE: &str = "website"; diff --git a/dongle-smartcontract/src/errors.rs b/dongle-smartcontract/src/errors.rs index 20db47a..d757187 100644 --- a/dongle-smartcontract/src/errors.rs +++ b/dongle-smartcontract/src/errors.rs @@ -85,3 +85,5 @@ pub enum ContractError { // Normalized name duplicate DuplicateProjectName = 60, } + +pub type Error = ContractError; diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index 069b45f..36005da 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -321,6 +321,7 @@ pub fn publish_review_event( let action_sym = match action { ReviewAction::Submitted => symbol_short!("SUBMITTED"), ReviewAction::Updated => symbol_short!("UPDATED"), + ReviewAction::Revised => symbol_short!("REVISED"), ReviewAction::Deleted => symbol_short!("DELETED"), }; @@ -328,6 +329,35 @@ pub fn publish_review_event( .publish((REVIEW, action_sym, project_id, reviewer), event_data); } +pub fn publish_review_revision_event( + env: &Env, + project_id: u64, + reviewer: Address, + revision_index: u32, + previous_rating: u32, + previous_content_cid: Option, + new_rating: u32, + new_content_cid: Option, +) { + use crate::types::ReviewRevisionEvent; + + let event_data = ReviewRevisionEvent { + project_id, + reviewer: reviewer.clone(), + revision_index, + previous_rating, + previous_content_cid, + new_rating, + new_content_cid, + timestamp: env.ledger().timestamp(), + }; + + env.events().publish( + (REVIEW, symbol_short!("REVISED"), project_id, reviewer), + event_data, + ); +} + pub fn publish_project_registered_event( env: &Env, project_id: u64, @@ -713,11 +743,7 @@ pub fn publish_verification_evidence_updated_event( timestamp: env.ledger().timestamp(), }; env.events().publish( - ( - symbol_short!("VERIFY"), - symbol_short!("EV_UPD"), - project_id, - ), + (symbol_short!("VERIFY"), symbol_short!("EV_UPD"), project_id), event_data, ); } @@ -982,7 +1008,6 @@ pub struct ContractClaimRejectedEvent { pub timestamp: u64, } - pub fn publish_project_claimable_set_event( env: &Env, project_id: u64, @@ -1148,7 +1173,6 @@ pub fn publish_contract_claim_rejected_event( ); } - pub fn publish_min_project_age_set_event( env: &Env, admin: Address, @@ -1823,7 +1847,11 @@ pub fn publish_verification_assigned_event( timestamp: env.ledger().timestamp(), }; env.events().publish( - (symbol_short!("VERIFY"), symbol_short!("ASSIGNED"), project_id), + ( + symbol_short!("VERIFY"), + symbol_short!("ASSIGNED"), + project_id, + ), event_data, ); } diff --git a/dongle-smartcontract/src/fee_manager.rs b/dongle-smartcontract/src/fee_manager.rs index 1492768..b9dff0c 100644 --- a/dongle-smartcontract/src/fee_manager.rs +++ b/dongle-smartcontract/src/fee_manager.rs @@ -97,7 +97,7 @@ impl FeeManager { } // set_fee enforces that token is Some when fees are non-zero, so this // ok_or branch is a defensive guard against corrupted storage state. - let token_address = config.token.ok_or(ContractError::NativeFeeNotSupported)?; + let token_address = config.token.ok_or(ContractError::FeeConfigNotSet)?; let client = soroban_sdk::token::Client::new(env, &token_address); // Transfer must succeed before we set the payment flag. // If transfer fails, this function returns early without setting the flag. @@ -230,7 +230,7 @@ impl FeeManager { return Err(ContractError::InvalidProjectData); } // Defensive guard — set_fee already rejects None token with non-zero fees. - let token_address = config.token.ok_or(ContractError::NativeFeeNotSupported)?; + let token_address = config.token.ok_or(ContractError::FeeConfigNotSet)?; let client = soroban_sdk::token::Client::new(env, &token_address); // Transfer must succeed before we set the payment flag. // If transfer fails, this function returns early without setting the flag. @@ -238,9 +238,10 @@ impl FeeManager { } // Only set payment flag after successful token transfer - env.storage() - .persistent() - .set(&StorageKey::RegistrationFeePaidForAddress(payer.clone()), &true); + env.storage().persistent().set( + &StorageKey::RegistrationFeePaidForAddress(payer.clone()), + &true, + ); // Store full payment details for getter let payment_record = FeePaymentRecord { @@ -273,7 +274,9 @@ impl FeeManager { ) -> Option { env.storage() .persistent() - .get(&ExtensionKey::RegistrationFeePaymentDetails(address.clone())) + .get(&ExtensionKey::RegistrationFeePaymentDetails( + address.clone(), + )) } /// Check if the registration fee has been paid for an address diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index b8ab1f0..72a25e8 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -1,40 +1,1329 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +#![allow(warnings)] +mod admin_action_log; mod admin_manager; +pub mod auth; mod bookmark_registry; mod collection_registry; -mod constants; +pub mod constants; mod dependency_registry; mod dispute_registry; -mod errors; +mod endorsement_registry; +pub mod errors; +pub mod events; mod featured_registry; +mod fee_manager; +mod project_registry; +pub mod rating_calculator; mod report_registry; -mod storage_keys; +pub mod review_registry; +pub mod storage_keys; +pub mod storage_manager; mod subscription_registry; mod timelock_manager; -mod types; -mod validation; +pub mod types; +pub mod utils; mod verification_registry; #[cfg(test)] -mod tests { - mod fixtures; - mod indexer; - mod maintainers; - mod index_limits; - mod bounty_metadata; -} +mod tests; + +use crate::admin_action_log::AdminActionLog; +use crate::admin_manager::AdminManager; +use crate::collection_registry::CollectionRegistry; +use crate::errors::ContractError; +use crate::featured_registry::FeaturedRegistry; +use crate::fee_manager::FeeManager; +use crate::project_registry::ProjectRegistry; +use crate::report_registry::ReportRegistry; +use crate::review_registry::ReviewRegistry; +use crate::storage_keys::ExtensionKey; +use crate::storage_manager::StorageManager; +use crate::timelock_manager::TimelockManager; +use crate::types::{ + AdminActionEntry, AdminProposal, ClaimRequest, ClaimStatus, Collection, ContractClaimRequest, + DependencyRef, DisputeResolutionAction, DisputeStatus, DuplicateDispute, FeeConfig, + FeePaymentRecord, Project, ProjectDependency, ProjectRegistrationParams, ProjectReport, + ProjectSortMode, ProjectStats, ProjectUpdateParams, ProposalPayload, Review, ReviewRevision, + ReviewSortMode, ReviewTombstone, SecurityContactStatus, TimelockAction, VerificationRecord, + VerificationStatus, +}; +use crate::verification_registry::VerificationRegistry; +use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; #[contract] pub struct DongleContract; #[contractimpl] impl DongleContract { + // --- Initialization & Admin Management --- + pub fn initialize(env: Env, admin: Address) { - admin_manager::AdminManager::initialize(&env, &admin); + AdminManager::initialize(&env, admin); + } + + pub fn add_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), ContractError> { + AdminManager::add_admin(&env, caller, new_admin) + } + + pub fn remove_admin( + env: Env, + caller: Address, + admin_to_remove: Address, + ) -> Result<(), ContractError> { + AdminManager::remove_admin(&env, caller, admin_to_remove) + } + + pub fn is_admin(env: Env, address: Address) -> bool { + AdminManager::is_admin(&env, &address) + } + + pub fn get_admin_list(env: Env) -> Vec
{ + AdminManager::get_admin_list(&env) + } + + pub fn get_admin_count(env: Env) -> u32 { + AdminManager::get_admin_count(&env) + } + + pub fn get_admin_approval_threshold(env: Env) -> u32 { + AdminManager::get_admin_approval_threshold(&env) + } + + pub fn set_admin_approval_threshold( + env: Env, + caller: Address, + threshold: u32, + ) -> Result<(), ContractError> { + AdminManager::set_admin_approval_threshold(&env, caller, threshold) + } + + pub fn create_proposal( + env: Env, + proposer: Address, + payload: ProposalPayload, + ) -> Result { + AdminManager::create_proposal(&env, proposer, payload) + } + + pub fn approve_proposal( + env: Env, + admin: Address, + proposal_id: u64, + ) -> Result<(), ContractError> { + AdminManager::approve_proposal(&env, admin, proposal_id) + } + + pub fn execute_proposal( + env: Env, + caller: Address, + proposal_id: u64, + ) -> Result<(), ContractError> { + AdminManager::execute_proposal(&env, caller, proposal_id) + } + + pub fn get_proposal(env: Env, proposal_id: u64) -> Option { + AdminManager::get_proposal(&env, proposal_id) + } + + // --- Project Registry --- + + pub fn register_project( + env: Env, + params: ProjectRegistrationParams, + ) -> Result { + ProjectRegistry::register_project(&env, params) + } + + pub fn update_project(env: Env, params: ProjectUpdateParams) -> Result { + ProjectRegistry::update_project(&env, params) + } + + pub fn update_security_contact( + env: Env, + project_id: u64, + caller: Address, + contact: Option, + ) -> Result { + ProjectRegistry::update_security_contact(&env, project_id, caller, contact) + } + + pub fn submit_security_contact_proof( + env: Env, + project_id: u64, + caller: Address, + proof_cid: String, + ) -> Result { + ProjectRegistry::submit_security_contact_proof(&env, project_id, caller, proof_cid) + } + + pub fn get_security_contact_status( + env: Env, + project_id: u64, + ) -> Result { + ProjectRegistry::get_security_contact_status(&env, project_id) + } + + pub fn link_project( + env: Env, + project_id: u64, + caller: Address, + linked_project_id: u64, + ) -> Result<(), ContractError> { + ProjectRegistry::link_project(&env, project_id, caller, linked_project_id) + } + + pub fn unlink_project( + env: Env, + project_id: u64, + caller: Address, + linked_project_id: u64, + ) -> Result<(), ContractError> { + ProjectRegistry::unlink_project(&env, project_id, caller, linked_project_id) + } + + pub fn get_linked_projects(env: Env, project_id: u64) -> Vec { + ProjectRegistry::get_linked_projects(&env, project_id) + } + + pub fn get_project(env: Env, project_id: u64) -> Option { + ProjectRegistry::get_project(&env, project_id) + } + + pub fn get_project_by_slug(env: Env, slug: String) -> Option { + ProjectRegistry::get_project_by_slug(&env, slug) + } + + pub fn initiate_transfer( + env: Env, + project_id: u64, + caller: Address, + new_owner: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::initiate_transfer(&env, project_id, caller, new_owner) + } + + pub fn cancel_transfer( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::cancel_transfer(&env, project_id, caller) + } + + pub fn accept_transfer( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::accept_transfer(&env, project_id, caller) + } + + pub fn list_projects(env: Env, start_id: u64, limit: u32) -> Vec { + ProjectRegistry::list_projects(&env, start_id, limit) + } + + pub fn get_projects_by_owner(env: Env, owner: Address) -> Vec { + ProjectRegistry::get_projects_by_owner(&env, owner) + } + + pub fn get_owner_project_count(env: Env, owner: Address) -> u32 { + ProjectRegistry::get_owner_project_count(&env, &owner) + } + + pub fn get_project_count(env: Env) -> u64 { + ProjectRegistry::get_project_count(&env) + } + + pub fn get_projects_by_ids(env: Env, ids: Vec) -> Vec { + ProjectRegistry::get_projects_by_ids(&env, ids) + } + + /// Sets an optional region tag for a project (owner only). + pub fn set_project_region( + env: Env, + project_id: u64, + caller: Address, + region: Option, + ) -> Result<(), ContractError> { + caller.require_auth(); + let project = + ProjectRegistry::get_project(&env, project_id).ok_or(ContractError::ProjectNotFound)?; + if project.owner != caller { + return Err(ContractError::Unauthorized); + } + match region { + Some(r) => env + .storage() + .persistent() + .set(&ExtensionKey::ProjectRegion(project_id), &r), + None => env + .storage() + .persistent() + .remove(&ExtensionKey::ProjectRegion(project_id)), + } + Ok(()) + } + + /// Returns the region tag for a project, if set. + pub fn get_project_region(env: Env, project_id: u64) -> Option { + env.storage() + .persistent() + .get(&ExtensionKey::ProjectRegion(project_id)) + } + + /// Returns the stored integrity hash for a project, if any. + pub fn get_project_integrity_hash(env: Env, project_id: u64) -> Option { + env.storage() + .persistent() + .get(&ExtensionKey::ProjectIntegrityHash(project_id)) + } + + pub fn list_projects_by_status( + env: Env, + status: VerificationStatus, + start_id: u64, + limit: u32, + ) -> Vec { + ProjectRegistry::list_projects_by_status(&env, status, start_id, limit) + } + + pub fn list_projects_by_category( + env: Env, + category: String, + start_id: u32, + limit: u32, + ) -> Vec { + ProjectRegistry::list_projects_by_category(&env, category, start_id, limit) + } + + pub fn list_projects_sorted( + env: Env, + sort_mode: ProjectSortMode, + start_id: u64, + limit: u32, + ) -> Vec { + ProjectRegistry::list_projects_sorted(&env, sort_mode, start_id, limit) + } + + pub fn claim_contract_address( + env: Env, + project_id: u64, + caller: Address, + contract_address: String, + proof_cid: String, + ) -> Result { + ProjectRegistry::claim_contract_address( + &env, + project_id, + caller, + contract_address, + proof_cid, + ) + } + + pub fn approve_contract_claim( + env: Env, + project_id: u64, + contract_address: String, + admin: Address, + ) -> Result { + ProjectRegistry::approve_contract_claim(&env, project_id, contract_address, admin) + } + + pub fn reject_contract_claim( + env: Env, + project_id: u64, + contract_address: String, + admin: Address, + ) -> Result { + ProjectRegistry::reject_contract_claim(&env, project_id, contract_address, admin) + } + + pub fn get_verified_contracts(env: Env, project_id: u64) -> Vec { + ProjectRegistry::get_verified_contracts(&env, project_id) + } + + pub fn archive_project( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::archive_project(&env, project_id, caller) + } + + pub fn reactivate_project( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::reactivate_project(&env, project_id, caller) + } + + pub fn add_maintainer( + env: Env, + project_id: u64, + caller: Address, + maintainer: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::add_maintainer(&env, project_id, caller, maintainer) + } + + pub fn remove_maintainer( + env: Env, + project_id: u64, + caller: Address, + maintainer: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::remove_maintainer(&env, project_id, caller, maintainer) + } + + pub fn get_maintainers(env: Env, project_id: u64) -> Vec
{ + ProjectRegistry::get_maintainers(&env, project_id) + } + + // --- Featured Registry --- + + pub fn set_featured( + env: Env, + admin: Address, + project_id: u64, + featured: bool, + ) -> Result<(), ContractError> { + FeaturedRegistry::set_featured(&env, admin, project_id, featured) + } + + pub fn list_featured_projects(env: Env, start: u32, limit: u32) -> Vec { + FeaturedRegistry::list_featured_projects(&env, start, limit) + } + + // --- Review Registry --- + + pub fn add_review( + env: Env, + project_id: u64, + reviewer: Address, + rating: u32, + comment_cid: Option, + ) -> Result<(), ContractError> { + ReviewRegistry::add_review(&env, project_id, reviewer, rating, comment_cid) + } + + pub fn update_review( + env: Env, + project_id: u64, + reviewer: Address, + rating: u32, + comment_cid: Option, + ) -> Result<(), ContractError> { + ReviewRegistry::update_review(&env, project_id, reviewer, rating, comment_cid) + } + + pub fn delete_review( + env: Env, + project_id: u64, + reviewer: Address, + ) -> Result<(), ContractError> { + ReviewRegistry::delete_review(&env, project_id, reviewer) + } + + pub fn submit_review( + env: Env, + project_id: u64, + reviewer: Address, + rating: u32, + review_cid: String, + ) -> Result<(), ContractError> { + ReviewRegistry::submit_review(&env, project_id, reviewer, rating, review_cid) + } + + pub fn respond_to_review( + env: Env, + project_id: u64, + caller: Address, + reviewer: Address, + response: String, + ) -> Result<(), ContractError> { + ReviewRegistry::respond_to_review(&env, project_id, caller, reviewer, response) + } + + pub fn get_review_response(env: Env, project_id: u64, reviewer: Address) -> Option { + ReviewRegistry::get_review_response(&env, project_id, reviewer) + } + + pub fn get_review(env: Env, project_id: u64, reviewer: Address) -> Option { + ReviewRegistry::get_review(&env, project_id, reviewer) + } + + pub fn get_review_cid(env: Env, project_id: u64, reviewer: Address) -> Option { + ReviewRegistry::get_review_cid(&env, project_id, reviewer) + } + + pub fn get_project_review_cids(env: Env, project_id: u64) -> Vec<(Address, String)> { + ReviewRegistry::get_project_review_cids(&env, project_id) + } + + pub fn get_reviews_by_ids(env: Env, ids: Vec<(u64, Address)>) -> Vec { + ReviewRegistry::get_reviews_by_ids(&env, ids) + } + + pub fn list_reviews(env: Env, project_id: u64, start_id: u32, limit: u32) -> Vec { + ReviewRegistry::list_reviews(&env, project_id, start_id, limit) + } + + pub fn get_project_stats(env: Env, project_id: u64) -> ProjectStats { + ReviewRegistry::get_project_stats(&env, project_id) + } + + /// Bayesian weighted rating (scaled by 100). See `RatingCalculator::calculate_weighted`. + pub fn get_weighted_rating(env: Env, project_id: u64) -> u32 { + ReviewRegistry::get_weighted_rating(&env, project_id) + } + + pub fn get_review_revision_count(env: Env, project_id: u64, reviewer: Address) -> u32 { + ReviewRegistry::get_review_revision_count(&env, project_id, reviewer) + } + + pub fn get_review_history( + env: Env, + project_id: u64, + reviewer: Address, + start_index: u32, + limit: u32, + ) -> Vec { + ReviewRegistry::get_review_history(&env, project_id, reviewer, start_index, limit) + } + + pub fn get_stats_batch(env: Env, ids: Vec) -> Vec<(u64, ProjectStats)> { + ReviewRegistry::get_stats_batch(&env, ids) + } + + pub fn set_reviews_enabled( + env: Env, + project_id: u64, + caller: Address, + enabled: bool, + ) -> Result<(), ContractError> { + ReviewRegistry::set_reviews_enabled(&env, project_id, caller, enabled) + } + + pub fn get_reviews_enabled(env: Env, project_id: u64) -> bool { + ReviewRegistry::get_reviews_enabled(&env, project_id) + } + + pub fn report_review( + env: Env, + project_id: u64, + reviewer: Address, + reporter: Address, + ) -> Result<(), ContractError> { + ReviewRegistry::report_review(&env, project_id, reviewer, reporter) + } + + pub fn hide_review( + env: Env, + project_id: u64, + reviewer: Address, + admin: Address, + ) -> Result<(), ContractError> { + ReviewRegistry::hide_review(&env, project_id, reviewer, admin) + } + + pub fn restore_review( + env: Env, + project_id: u64, + reviewer: Address, + admin: Address, + ) -> Result<(), ContractError> { + ReviewRegistry::restore_review(&env, project_id, reviewer, admin) + } + + /// Admin hard-delete a review permanently (admin-only). + pub fn admin_delete_review( + env: Env, + project_id: u64, + reviewer: Address, + admin: Address, + ) -> Result<(), ContractError> { + ReviewRegistry::admin_delete_review(&env, project_id, reviewer, admin) + } + + /// Get the deletion tombstone for a review, distinguishing deleted vs never-existed. + pub fn get_review_tombstone( + env: Env, + project_id: u64, + reviewer: Address, + ) -> Option { + ReviewRegistry::get_review_tombstone(&env, project_id, reviewer) + } + + /// List reviews sorted by the given sort mode with pagination. + /// Sorting is performed on-chain in-memory; compute cost scales with review count. + pub fn list_reviews_sorted( + env: Env, + project_id: u64, + start_id: u32, + limit: u32, + sort_mode: ReviewSortMode, + ) -> Vec { + ReviewRegistry::list_reviews_sorted(&env, project_id, start_id, limit, sort_mode) + } + + // --- Verification Registry --- + + pub fn request_verification( + env: Env, + project_id: u64, + requester: Address, + evidence_cid: String, + ) -> Result<(), ContractError> { + VerificationRegistry::request_verification(&env, project_id, requester, evidence_cid) + } + + /// Update the verification evidence CID for a pending verification request. + /// + /// # Restrictions + /// - Only the project owner can update the evidence. + /// - Updates are allowed only when the request status is `Pending`. + /// - Once a request is finalized (either Approved/Verified or Rejected), it is immutable + /// and further updates will be rejected with an error. + /// + /// # Validation + /// - The new evidence CID is validated using the project's standard IPFS CID rules. + /// Malformed or empty CIDs are rejected. + /// + /// # Events + /// - On a successful update, emits a `VerificationEvidenceUpdatedEvent` event. + pub fn update_verification_evidence( + env: Env, + project_id: u64, + caller: Address, + new_evidence_cid: String, + ) -> Result<(), ContractError> { + VerificationRegistry::update_verification_evidence( + &env, + project_id, + caller, + new_evidence_cid, + ) + } + + pub fn approve_verification( + env: Env, + project_id: u64, + admin: Address, + ) -> Result<(), ContractError> { + VerificationRegistry::approve_verification(&env, project_id, admin) + } + + pub fn reject_verification( + env: Env, + project_id: u64, + admin: Address, + ) -> Result<(), ContractError> { + VerificationRegistry::reject_verification(&env, project_id, admin) + } + + pub fn revoke_verification( + env: Env, + project_id: u64, + admin: Address, + reason: String, + ) -> Result<(), ContractError> { + VerificationRegistry::revoke_verification(&env, project_id, admin, reason) + } + + pub fn get_verification( + env: Env, + project_id: u64, + ) -> Result { + VerificationRegistry::get_verification(&env, project_id) + } + + pub fn get_verification_record( + env: Env, + request_id: u64, + ) -> Result { + VerificationRegistry::get_verification_record(&env, request_id) + } + + pub fn get_verifications_batch(env: Env, ids: Vec) -> Vec<(u64, VerificationRecord)> { + VerificationRegistry::get_verifications_batch(&env, ids) + } + + pub fn get_verification_history(env: Env, project_id: u64) -> Vec { + VerificationRegistry::get_verification_history(&env, project_id) + } + + pub fn request_renewal( + env: Env, + project_id: u64, + requester: Address, + evidence_cid: String, + ) -> Result<(), ContractError> { + VerificationRegistry::request_renewal(&env, project_id, requester, evidence_cid) + } + + pub fn approve_renewal(env: Env, project_id: u64, admin: Address) -> Result<(), ContractError> { + VerificationRegistry::approve_renewal(&env, project_id, admin) + } + + pub fn reject_renewal(env: Env, project_id: u64, admin: Address) -> Result<(), ContractError> { + VerificationRegistry::reject_renewal(&env, project_id, admin) + } + + pub fn get_renewal_request( + env: Env, + project_id: u64, + ) -> Result { + VerificationRegistry::get_renewal_request(&env, project_id) + } + + pub fn get_renewal_history( + env: Env, + project_id: u64, + start_index: u32, + limit: u32, + ) -> Vec { + VerificationRegistry::get_renewal_history(&env, project_id, start_index, limit) + } + + pub fn is_verification_expired(env: Env, project_id: u64) -> Result { + VerificationRegistry::is_verification_expired(&env, project_id) + } + + /// Admin: prune verification history, keeping the most recent `keep_count` records. + /// Returns the number of records removed. + pub fn clear_verification_history( + env: Env, + project_id: u64, + admin: Address, + keep_count: u32, + ) -> Result { + VerificationRegistry::clear_verification_history(&env, project_id, &admin, keep_count) + } + + /// Admin: clear all renewal history records for a project. + /// Returns the number of records removed. + pub fn clear_renewal_history( + env: Env, + project_id: u64, + admin: Address, + ) -> Result { + VerificationRegistry::clear_renewal_history(&env, project_id, &admin) + } + + // --- Verification Assignment --- + + /// Admin: assign a pending verification to a specific admin for review. + pub fn assign_verification( + env: Env, + project_id: u64, + admin: Address, + assignee: Address, + ) -> Result<(), ContractError> { + VerificationRegistry::assign_verification(&env, project_id, admin, assignee) + } + + /// Get the admin assigned to review a verification request. + pub fn get_assigned_admin(env: Env, project_id: u64) -> Result, ContractError> { + VerificationRegistry::get_assigned_admin(&env, project_id) + } + + // --- Reserved Project Names --- + + /// Admin: add a name to the reserved list. + pub fn add_reserved_name(env: Env, admin: Address, name: String) -> Result<(), ContractError> { + ProjectRegistry::add_reserved_name(&env, admin, name) + } + + /// Admin: remove a name from the reserved list. + pub fn remove_reserved_name( + env: Env, + admin: Address, + name: String, + ) -> Result<(), ContractError> { + ProjectRegistry::remove_reserved_name(&env, admin, name) + } + + /// Get the list of reserved project names. + pub fn get_reserved_names(env: Env) -> Vec { + ProjectRegistry::get_reserved_names(&env) + } + + /// Check if a specific name is reserved. + pub fn is_name_reserved(env: Env, name: String) -> bool { + ProjectRegistry::is_name_reserved(&env, &name) + } + + // --- Fee Manager --- + + pub fn set_fee( + env: Env, + admin: Address, + token: Option
, + verification_fee: u128, + registration_fee: u128, + treasury: Address, + ) -> Result<(), ContractError> { + FeeManager::set_fee( + &env, + admin, + token, + verification_fee, + registration_fee, + treasury, + ) + } + + pub fn pay_fee( + env: Env, + payer: Address, + project_id: u64, + token: Option
, + ) -> Result<(), ContractError> { + FeeManager::pay_fee(&env, payer, project_id, token) + } + + pub fn is_fee_paid(env: Env, project_id: u64) -> bool { + FeeManager::is_fee_paid(&env, project_id) + } + + pub fn pay_registration_fee( + env: Env, + payer: Address, + token: Option
, + ) -> Result<(), ContractError> { + FeeManager::pay_registration_fee(&env, payer, token) + } + + pub fn get_fee_config(env: Env) -> Result { + FeeManager::get_fee_config(&env) + } + + /// Get fee payment details for a project (payer, amount, token, timestamp). + pub fn get_fee_payment_details(env: Env, project_id: u64) -> Option { + FeeManager::get_fee_payment_details(&env, project_id) + } + + /// Get registration fee payment details for an address. + pub fn get_reg_fee_payment_details(env: Env, address: Address) -> Option { + FeeManager::get_registration_fee_payment_details(&env, &address) + } + + // --- TTL Management --- + + /// Extend TTL for a specific project and its related data + pub fn extend_project_ttl(env: Env, project_id: u64) { + if let Some(project) = ProjectRegistry::get_project(&env, project_id) { + StorageManager::extend_project_full_ttl(&env, project_id, &project.name); + } + } + + /// Extend TTL for a specific review + pub fn extend_review_ttl(env: Env, project_id: u64, reviewer: Address) { + StorageManager::extend_review_ttl(&env, project_id, &reviewer); + } + + /// Extend TTL for all admin-related data + pub fn extend_admin_ttl(env: Env, admin: Address) { + StorageManager::extend_all_admin_ttl(&env, &admin); + } + + /// Extend TTL for critical contract configuration (admin list, fee config, treasury) + pub fn extend_critical_config_ttl(env: Env) { + StorageManager::extend_critical_config_ttl(&env); + } + + /// Extend TTL for user-related data (owner projects, user reviews) + pub fn extend_user_ttl(env: Env, user: Address) { + StorageManager::extend_owner_projects_ttl(&env, &user); + StorageManager::extend_user_reviews_ttl(&env, &user); + } + + /// Extend TTL for verification data + pub fn extend_verification_ttl(env: Env, project_id: u64) { + StorageManager::extend_verification_ttl(&env, project_id); + StorageManager::extend_fee_paid_ttl(&env, project_id); + } + + // --- New Features --- + + /// Set minimum project age before verification (admin only) - Issue #130 + pub fn set_min_project_age( + env: Env, + admin: Address, + min_age_seconds: u64, + ) -> Result<(), ContractError> { + VerificationRegistry::set_min_project_age(&env, admin, min_age_seconds) + } + + /// Get minimum project age configuration - Issue #130 + pub fn get_min_project_age(env: Env) -> u64 { + VerificationRegistry::get_min_project_age(&env) + } + + /// Set verification duration (admin only) + pub fn set_verification_duration( + env: Env, + admin: Address, + duration_seconds: u64, + ) -> Result<(), ContractError> { + VerificationRegistry::set_verification_duration(&env, admin, duration_seconds) + } + + /// Get verification duration configuration + pub fn get_verification_duration(env: Env) -> u64 { + VerificationRegistry::get_verification_duration(&env) + } + + /// Report a project for spam, scams, broken links, or abusive metadata - Issue #127 + pub fn report_project( + env: Env, + project_id: u64, + reporter: Address, + reason_cid: String, + ) -> Result<(), ContractError> { + ReportRegistry::report_project(&env, project_id, reporter, reason_cid) + } + + /// Get all reports for a project - Issue #127 + pub fn get_project_reports(env: Env, project_id: u64) -> Vec { + ReportRegistry::get_project_reports(&env, project_id) + } + + /// Get report count for a project - Issue #127 + pub fn get_project_report_count(env: Env, project_id: u64) -> u32 { + ReportRegistry::get_project_report_count(&env, project_id) + } + + /// Check if a user has already reported a project - Issue #127 + pub fn has_user_reported(env: Env, project_id: u64, reporter: Address) -> bool { + ReportRegistry::has_user_reported(&env, project_id, &reporter) + } + + /// Admin: clear all reports for a project (admin-only). + pub fn clear_project_reports( + env: Env, + project_id: u64, + admin: Address, + ) -> Result<(), ContractError> { + ReportRegistry::clear_project_reports(&env, project_id, &admin) + } + + /// List projects by tag - Issue #125 + pub fn list_projects_by_tag(env: Env, tag: String, start_id: u32, limit: u32) -> Vec { + ProjectRegistry::list_projects_by_tag(&env, tag, start_id, limit) + } + + // --- Collection Registry --- + + /// Admin: create a new curated collection of projects. + pub fn create_collection( + env: Env, + admin: Address, + name: String, + description: String, + ) -> Result { + CollectionRegistry::create_collection(&env, admin, name, description) + } + + /// Admin: update a collection's name and description. + pub fn update_collection( + env: Env, + admin: Address, + collection_id: u64, + name: String, + description: String, + ) -> Result<(), ContractError> { + CollectionRegistry::update_collection(&env, admin, collection_id, name, description) + } + + /// Admin: delete a collection and its project associations. + pub fn delete_collection( + env: Env, + admin: Address, + collection_id: u64, + ) -> Result<(), ContractError> { + CollectionRegistry::delete_collection(&env, admin, collection_id) + } + + /// Admin: add a project to a collection. + pub fn add_project_to_collection( + env: Env, + admin: Address, + collection_id: u64, + project_id: u64, + ) -> Result<(), ContractError> { + CollectionRegistry::add_project_to_collection(&env, admin, collection_id, project_id) + } + + /// Admin: remove a project from a collection. + pub fn remove_project_from_collection( + env: Env, + admin: Address, + collection_id: u64, + project_id: u64, + ) -> Result<(), ContractError> { + CollectionRegistry::remove_project_from_collection(&env, admin, collection_id, project_id) + } + + /// Get a collection by ID. + pub fn get_collection(env: Env, collection_id: u64) -> Result { + CollectionRegistry::get_collection(&env, collection_id) + } + + /// List all collections with pagination. + pub fn list_collections(env: Env, start: u32, limit: u32) -> Vec { + CollectionRegistry::list_collections(&env, start, limit) + } + + /// List project IDs in a collection with pagination. + pub fn list_collection_projects( + env: Env, + collection_id: u64, + start: u32, + limit: u32, + ) -> Vec { + CollectionRegistry::list_collection_projects(&env, collection_id, start, limit) + } + + /// Get the number of projects in a collection. + pub fn get_collection_project_count(env: Env, collection_id: u64) -> u32 { + CollectionRegistry::get_collection_project_count(&env, collection_id) + } + + /// Get the total number of collections. + pub fn get_collection_count(env: Env) -> u64 { + CollectionRegistry::get_collection_count(&env) } - // Placeholder for actual contract methods used elsewhere. - // Full implementation resides in the registry modules. + // --- Admin Action Log --- + + /// Get a single admin action log entry by ID. + pub fn get_admin_action_log_entry(env: Env, log_id: u64) -> Option { + AdminActionLog::get_log_entry(&env, log_id) + } + + /// List admin action log entries with pagination (most recent first). + pub fn list_admin_actions(env: Env, start: u32, limit: u32) -> Vec { + AdminActionLog::list_admin_actions(&env, start, limit) + } + + /// Get the total number of admin action log entries. + pub fn get_admin_action_log_count(env: Env) -> u64 { + AdminActionLog::get_action_log_count(&env) + } + + // --- Project Claiming --- + + pub fn set_project_claimable( + env: Env, + project_id: u64, + caller: Address, + claimable: bool, + ) -> Result<(), ContractError> { + ProjectRegistry::set_project_claimable(&env, project_id, caller, claimable) + } + + pub fn submit_claim_request( + env: Env, + project_id: u64, + claimant: Address, + proof_cid: String, + ) -> Result { + ProjectRegistry::submit_claim_request(&env, project_id, claimant, proof_cid) + } + + pub fn approve_claim_request( + env: Env, + claim_request_id: u64, + admin: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::approve_claim_request(&env, claim_request_id, admin) + } + + pub fn reject_claim_request( + env: Env, + claim_request_id: u64, + admin: Address, + ) -> Result<(), ContractError> { + ProjectRegistry::reject_claim_request(&env, claim_request_id, admin) + } + + pub fn get_claim_request(env: Env, claim_request_id: u64) -> Option { + ProjectRegistry::get_claim_request(&env, claim_request_id) + } + + pub fn get_claim_requests_for_project(env: Env, project_id: u64) -> Vec { + ProjectRegistry::get_claim_requests_for_project(&env, project_id) + } + + // --- Project Dependencies --- + + pub fn add_project_dependency( + env: Env, + project_id: u64, + caller: Address, + dependency: ProjectDependency, + ) -> Result<(), ContractError> { + crate::dependency_registry::DependencyRegistry::add_dependency( + &env, project_id, caller, dependency, + ) + } + + pub fn update_project_dependency( + env: Env, + project_id: u64, + caller: Address, + dependency_key: DependencyRef, + new_dependency: ProjectDependency, + ) -> Result<(), ContractError> { + crate::dependency_registry::DependencyRegistry::update_dependency( + &env, + project_id, + caller, + dependency_key, + new_dependency, + ) + } + + pub fn remove_project_dependency( + env: Env, + project_id: u64, + caller: Address, + dependency_key: DependencyRef, + ) -> Result<(), ContractError> { + crate::dependency_registry::DependencyRegistry::remove_dependency( + &env, + project_id, + caller, + dependency_key, + ) + } + + pub fn get_project_dependencies(env: Env, project_id: u64) -> Vec { + crate::dependency_registry::DependencyRegistry::get_dependencies(&env, project_id) + } + + // --- Duplicate Disputes --- + + pub fn open_duplicate_dispute( + env: Env, + project_id: u64, + original_project_id: u64, + creator: Address, + evidence_cid: String, + ) -> Result { + crate::dispute_registry::DisputeRegistry::open_duplicate_dispute( + &env, + project_id, + original_project_id, + creator, + evidence_cid, + ) + } + + pub fn resolve_duplicate_dispute( + env: Env, + dispute_id: u64, + admin: Address, + action: DisputeResolutionAction, + ) -> Result<(), ContractError> { + crate::dispute_registry::DisputeRegistry::resolve_duplicate_dispute( + &env, dispute_id, admin, action, + ) + } + + pub fn get_duplicate_dispute(env: Env, dispute_id: u64) -> Option { + crate::dispute_registry::DisputeRegistry::get_duplicate_dispute(&env, dispute_id) + } + + pub fn get_disputes_for_project(env: Env, project_id: u64) -> Vec { + crate::dispute_registry::DisputeRegistry::get_disputes_for_project(&env, project_id) + } + + // --- Subscription / Follow --- + + pub fn follow_project( + env: Env, + project_id: u64, + follower: Address, + ) -> Result<(), ContractError> { + crate::subscription_registry::SubscriptionRegistry::follow_project( + &env, project_id, follower, + ) + } + + pub fn unfollow_project( + env: Env, + project_id: u64, + follower: Address, + ) -> Result<(), ContractError> { + crate::subscription_registry::SubscriptionRegistry::unfollow_project( + &env, project_id, follower, + ) + } + + pub fn get_follower_count(env: Env, project_id: u64) -> u32 { + crate::subscription_registry::SubscriptionRegistry::get_follower_count(&env, project_id) + } + + pub fn is_following(env: Env, project_id: u64, user: Address) -> bool { + crate::subscription_registry::SubscriptionRegistry::is_following(&env, project_id, &user) + } + + pub fn get_project_followers( + env: Env, + project_id: u64, + start: u32, + limit: u32, + ) -> Vec
{ + crate::subscription_registry::SubscriptionRegistry::get_project_followers( + &env, project_id, start, limit, + ) + } + + pub fn get_user_subscriptions(env: Env, user: Address, start: u32, limit: u32) -> Vec { + crate::subscription_registry::SubscriptionRegistry::get_user_subscriptions( + &env, user, start, limit, + ) + } + + // --- Bookmark Registry --- + + pub fn bookmark_project( + env: Env, + project_id: u64, + user: Address, + ) -> Result<(), crate::bookmark_registry::BookmarkError> { + crate::bookmark_registry::BookmarkRegistry::bookmark_project(&env, project_id, user) + } + + pub fn unbookmark_project( + env: Env, + project_id: u64, + user: Address, + ) -> Result<(), crate::bookmark_registry::BookmarkError> { + crate::bookmark_registry::BookmarkRegistry::unbookmark_project(&env, project_id, user) + } + + pub fn is_bookmarked(env: Env, project_id: u64, user: Address) -> bool { + crate::bookmark_registry::BookmarkRegistry::is_bookmarked(&env, project_id, &user) + } + + pub fn get_user_bookmarks(env: Env, user: Address, start: u32, limit: u32) -> Vec { + crate::bookmark_registry::BookmarkRegistry::get_user_bookmarks(&env, user, start, limit) + } + + // --- Endorsement Registry --- + + pub fn endorse_project( + env: Env, + project_id: u64, + user: Address, + ) -> Result<(), crate::endorsement_registry::EndorsementError> { + crate::endorsement_registry::EndorsementRegistry::endorse_project(&env, project_id, user) + } + + pub fn unendorse_project( + env: Env, + project_id: u64, + user: Address, + ) -> Result<(), crate::endorsement_registry::EndorsementError> { + crate::endorsement_registry::EndorsementRegistry::unendorse_project(&env, project_id, user) + } + + pub fn get_endorsement_count(env: Env, project_id: u64) -> u32 { + crate::endorsement_registry::EndorsementRegistry::get_endorsement_count(&env, project_id) + } + + pub fn has_endorsed(env: Env, project_id: u64, user: Address) -> bool { + crate::endorsement_registry::EndorsementRegistry::has_endorsed(&env, project_id, &user) + } + + // --- Admin Timelock --- + + pub fn schedule_set_fee( + env: Env, + admin: Address, + token: Option
, + verification_fee: u128, + registration_fee: u128, + treasury: Address, + execution_timestamp: u64, + ) -> Result { + TimelockManager::schedule_set_fee( + &env, + admin, + token, + verification_fee, + registration_fee, + treasury, + execution_timestamp, + ) + } + + pub fn schedule_add_admin( + env: Env, + admin: Address, + new_admin: Address, + execution_timestamp: u64, + ) -> Result { + TimelockManager::schedule_add_admin(&env, admin, new_admin, execution_timestamp) + } + + pub fn schedule_remove_admin( + env: Env, + admin: Address, + admin_to_remove: Address, + execution_timestamp: u64, + ) -> Result { + TimelockManager::schedule_remove_admin(&env, admin, admin_to_remove, execution_timestamp) + } + + pub fn cancel_scheduled_action( + env: Env, + caller: Address, + action_id: u64, + ) -> Result<(), ContractError> { + TimelockManager::cancel_action(&env, caller, action_id) + } + + pub fn execute_scheduled_set_fee( + env: Env, + caller: Address, + action_id: u64, + ) -> Result<(), ContractError> { + TimelockManager::execute_set_fee(&env, caller, action_id) + } + + pub fn execute_scheduled_add_admin( + env: Env, + caller: Address, + action_id: u64, + ) -> Result<(), ContractError> { + TimelockManager::execute_add_admin(&env, caller, action_id) + } + + pub fn execute_scheduled_remove_admin( + env: Env, + caller: Address, + action_id: u64, + ) -> Result<(), ContractError> { + TimelockManager::execute_remove_admin(&env, caller, action_id) + } + + pub fn get_scheduled_action(env: Env, action_id: u64) -> Option { + TimelockManager::get_action(&env, action_id) + } + + pub fn list_scheduled_actions(env: Env, start: u32, limit: u32) -> Vec { + TimelockManager::list_scheduled_actions(&env, start, limit) + } + + pub fn get_scheduled_action_count(env: Env) -> u64 { + TimelockManager::get_scheduled_action_count(&env) + } } diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index 17c2082..c5d72d0 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -15,8 +15,9 @@ use crate::fee_manager::FeeManager; use crate::storage_keys::{ExtensionKey, StorageKey}; use crate::storage_manager::StorageManager; use crate::types::{ - ClaimRequest, ClaimStatus, Project, ProjectRegistrationParams, ProjectUpdateParams, - SecurityContactStatus, VerificationStatus, ContractClaimRequest, ContractClaimStatus, ProjectSortMode, + ClaimRequest, ClaimStatus, ContractClaimRequest, ContractClaimStatus, Project, + ProjectRegistrationParams, ProjectSortMode, ProjectUpdateParams, SecurityContactStatus, + VerificationStatus, }; use crate::utils::Utils; use soroban_sdk::{Address, Bytes, Env, String, Vec}; @@ -212,7 +213,14 @@ impl ProjectRegistry { .set(&StorageKey::ProjectBountyUrl(count), bounty_url); } - Self::store_integrity_hash(env, count, &project.name, &project.slug, &project.category, &project.description); + Self::store_integrity_hash( + env, + count, + &project.name, + &project.slug, + &project.category, + &project.description, + ); publish_project_registered_event( env, @@ -608,7 +616,14 @@ impl ProjectRegistry { StorageManager::extend_project_stats_ttl(env, params.project_id); } - Self::store_integrity_hash(env, params.project_id, &project.name, &project.slug, &project.category, &project.description); + Self::store_integrity_hash( + env, + params.project_id, + &project.name, + &project.slug, + &project.category, + &project.description, + ); publish_project_updated_event(env, params.project_id, project.owner.clone()); if major_metadata_changed { @@ -1696,11 +1711,7 @@ impl ProjectRegistry { } /// Admin: add a name to the reserved list. - pub fn add_reserved_name( - env: &Env, - admin: Address, - name: String, - ) -> Result<(), ContractError> { + pub fn add_reserved_name(env: &Env, admin: Address, name: String) -> Result<(), ContractError> { crate::auth::require_admin_auth(env, &admin)?; let mut reserved: Vec = env @@ -1801,6 +1812,230 @@ impl ProjectRegistry { Self::check_reserved_name(env, name).is_err() } + pub fn claim_contract_address( + env: &Env, + project_id: u64, + caller: Address, + contract_address: String, + proof_cid: String, + ) -> Result { + let project = Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; + caller.require_auth(); + let is_owner = project.owner == caller; + let is_maintainer = Self::is_maintainer(env, project_id, &caller); + if !is_owner && !is_maintainer { + return Err(ContractError::Unauthorized); + } + + Utils::validate_metadata_cid(&proof_cid)?; + + let req = ContractClaimRequest { + project_id, + contract_address: contract_address.clone(), + claimant: caller.clone(), + proof_cid: proof_cid.clone(), + status: ContractClaimStatus::Pending, + created_at: env.ledger().timestamp(), + }; + + env.storage().persistent().set( + &ExtensionKey::ContractClaim(project_id, contract_address.clone()), + &req, + ); + + crate::events::publish_contract_claim_submitted_event( + env, + project_id, + contract_address, + caller, + proof_cid, + ); + Ok(req) + } + + pub fn approve_contract_claim( + env: &Env, + project_id: u64, + contract_address: String, + admin: Address, + ) -> Result { + AdminManager::require_admin(env, &admin)?; + let mut req: ContractClaimRequest = env + .storage() + .persistent() + .get(&ExtensionKey::ContractClaim( + project_id, + contract_address.clone(), + )) + .ok_or(ContractError::InvalidProjectData)?; + + if req.status != ContractClaimStatus::Pending { + return Err(ContractError::InvalidProjectData); + } + + req.status = ContractClaimStatus::Approved; + env.storage().persistent().set( + &ExtensionKey::ContractClaim(project_id, contract_address.clone()), + &req, + ); + + let mut contracts: Vec = env + .storage() + .persistent() + .get(&ExtensionKey::ProjectContracts(project_id)) + .unwrap_or_else(|| Vec::new(env)); + contracts.push_back(contract_address.clone()); + env.storage() + .persistent() + .set(&ExtensionKey::ProjectContracts(project_id), &contracts); + + crate::events::publish_contract_claim_approved_event( + env, + project_id, + contract_address, + admin, + ); + Ok(req) + } + + pub fn reject_contract_claim( + env: &Env, + project_id: u64, + contract_address: String, + admin: Address, + ) -> Result { + AdminManager::require_admin(env, &admin)?; + let mut req: ContractClaimRequest = env + .storage() + .persistent() + .get(&ExtensionKey::ContractClaim( + project_id, + contract_address.clone(), + )) + .ok_or(ContractError::InvalidProjectData)?; + + if req.status != ContractClaimStatus::Pending { + return Err(ContractError::InvalidProjectData); + } + + req.status = ContractClaimStatus::Rejected; + env.storage().persistent().set( + &ExtensionKey::ContractClaim(project_id, contract_address.clone()), + &req, + ); + + crate::events::publish_contract_claim_rejected_event( + env, + project_id, + contract_address, + admin, + ); + Ok(req) + } + + pub fn get_verified_contracts(env: &Env, project_id: u64) -> Vec { + env.storage() + .persistent() + .get(&ExtensionKey::ProjectContracts(project_id)) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn list_projects_sorted( + env: &Env, + sort_mode: ProjectSortMode, + start_id: u64, + limit: u32, + ) -> Vec { + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; + + let count: u64 = env + .storage() + .persistent() + .get(&StorageKey::ProjectCount) + .unwrap_or(0); + + let mut all: Vec = Vec::new(env); + for id in 1..=count { + if let Some(project) = Self::get_project(env, id) { + if !project.archived { + all.push_back(project); + } + } + } + + let n = all.len(); + for i in 0..n { + for j in 0..n.saturating_sub(i + 1) { + let a = all.get(j).unwrap(); + let b = all.get(j + 1).unwrap(); + let mut swap = false; + match sort_mode { + ProjectSortMode::Newest => { + if a.created_at < b.created_at { + swap = true; + } + } + ProjectSortMode::Oldest => { + if a.created_at > b.created_at { + swap = true; + } + } + ProjectSortMode::HighestRated | ProjectSortMode::MostReviewed => { + let stats_a = + crate::review_registry::ReviewRegistry::get_project_stats(env, a.id); + let stats_b = + crate::review_registry::ReviewRegistry::get_project_stats(env, b.id); + if sort_mode == ProjectSortMode::HighestRated { + if stats_a.average_rating < stats_b.average_rating { + swap = true; + } else if stats_a.average_rating == stats_b.average_rating + && stats_a.review_count < stats_b.review_count + { + swap = true; + } + } else if stats_a.review_count < stats_b.review_count { + swap = true; + } else if stats_a.review_count == stats_b.review_count + && stats_a.average_rating < stats_b.average_rating + { + swap = true; + } + } + } + if swap { + all.set(j, b); + all.set(j + 1, a); + } + } + } + + let mut result = Vec::new(env); + let start = start_id as u32; + if start < n { + let end = core::cmp::min(start.saturating_add(effective_limit), n); + for i in start..end { + if let Some(project) = all.get(i) { + result.push_back(project); + } + } + } + + result + } + + fn append_string_bytes(_env: &Env, buf: &mut soroban_sdk::Bytes, s: &String) { + let len = s.len() as usize; + let mut scratch = [0u8; crate::constants::MAX_DESCRIPTION_LEN]; + s.copy_into_slice(&mut scratch[..len]); + for i in 0..len { + buf.push_back(scratch[i]); + } + } + /// Computes and stores a SHA-256 integrity hash over key project metadata fields. /// The hash input is the concatenation: name|slug|category|description (pipe-separated). pub fn store_integrity_hash( @@ -1812,38 +2047,22 @@ impl ProjectRegistry { description: &String, ) { let sep = b'|'; - let name_bytes = name.to_string(); - let slug_bytes = slug.to_string(); - let cat_bytes = category.to_string(); - let desc_bytes = description.to_string(); - - let total_len = name_bytes.len() + 1 + slug_bytes.len() + 1 + cat_bytes.len() + 1 + desc_bytes.len(); - let mut buf = Bytes::new(env); - for b in name_bytes.as_bytes() { - buf.push_back(*b); - } + let mut buf = soroban_sdk::Bytes::new(env); + Self::append_string_bytes(env, &mut buf, name); buf.push_back(sep); - for b in slug_bytes.as_bytes() { - buf.push_back(*b); - } + Self::append_string_bytes(env, &mut buf, slug); buf.push_back(sep); - for b in cat_bytes.as_bytes() { - buf.push_back(*b); - } + Self::append_string_bytes(env, &mut buf, category); buf.push_back(sep); - for b in desc_bytes.as_bytes() { - buf.push_back(*b); - } - let _ = total_len; + Self::append_string_bytes(env, &mut buf, description); let hash = env.crypto().sha256(&buf); - let hash_bytes = Bytes::from_array(env, &hash.to_array()); + let hash_bytes = soroban_sdk::Bytes::from_array(env, &hash.to_array()); env.storage() .persistent() .set(&ExtensionKey::ProjectIntegrityHash(project_id), &hash_bytes); } } - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/dongle-smartcontract/src/rating_calculator.rs b/dongle-smartcontract/src/rating_calculator.rs index bd70ad3..6ce6855 100644 --- a/dongle-smartcontract/src/rating_calculator.rs +++ b/dongle-smartcontract/src/rating_calculator.rs @@ -83,6 +83,31 @@ impl RatingCalculator { let new_average = Self::calculate_average(new_sum, new_count); (new_sum, new_count, new_average) } + + /// Calculate Bayesian weighted rating using stored aggregates. + /// + /// Formula (result scaled by 100, same as `average_rating`): + /// ```text + /// weighted = (C * m + rating_sum) / (C + review_count) + /// ``` + /// Where `C` = `WEIGHTED_RATING_PRIOR_COUNT`, `m` = `WEIGHTED_RATING_PRIOR_MEAN`, + /// and `rating_sum` is the sum of individual ratings each scaled by 100. + /// + /// Edge cases: + /// - `review_count == 0` → returns prior mean `m` + /// - `review_count == 1` → blends prior with the single review + /// - large `review_count` → converges toward the arithmetic mean + pub fn calculate_weighted(rating_sum: u64, review_count: u32) -> u32 { + use crate::constants::{WEIGHTED_RATING_PRIOR_COUNT, WEIGHTED_RATING_PRIOR_MEAN}; + let c = WEIGHTED_RATING_PRIOR_COUNT as u64; + let m = WEIGHTED_RATING_PRIOR_MEAN as u64; + let numerator = c.saturating_mul(m).saturating_add(rating_sum); + let denominator = c.saturating_add(review_count as u64); + if denominator == 0 { + return WEIGHTED_RATING_PRIOR_MEAN; + } + (numerator / denominator) as u32 + } } #[cfg(test)] diff --git a/dongle-smartcontract/src/report_registry.rs b/dongle-smartcontract/src/report_registry.rs index 2a65d41..af1378f 100644 --- a/dongle-smartcontract/src/report_registry.rs +++ b/dongle-smartcontract/src/report_registry.rs @@ -119,7 +119,7 @@ impl ReportRegistry { let count = Self::get_project_report_count(env, project_id); if count == 0 { - return Err(ContractError::ReportsCleared); + return Err(ContractError::InvalidStatus); } // Gather existing reports to remove individual UserReport dedup keys diff --git a/dongle-smartcontract/src/review_registry.rs b/dongle-smartcontract/src/review_registry.rs index 1e499f4..9cd91e7 100644 --- a/dongle-smartcontract/src/review_registry.rs +++ b/dongle-smartcontract/src/review_registry.rs @@ -2,58 +2,25 @@ use crate::admin_action_log::AdminActionLog; use crate::constants::{ - MAX_CID_LEN, MAX_PAGE_LIMIT, MAX_REVIEWS_PER_PROJECT, MAX_REVIEWS_PER_USER, RATING_MAX, - RATING_MIN, REVIEW_UPDATE_COOLDOWN_SECONDS, + MAX_CID_LEN, MAX_PAGE_LIMIT, MAX_REVIEWS_PER_PROJECT, MAX_REVIEWS_PER_USER, + MAX_REVIEW_REVISIONS, RATING_MAX, RATING_MIN, REVIEW_UPDATE_COOLDOWN_SECONDS, }; use crate::errors::ContractError; -use crate::events::publish_review_event; +use crate::events::{publish_review_event, publish_review_revision_event}; use crate::project_registry::ProjectRegistry; use crate::rating_calculator::RatingCalculator; use crate::storage_keys::{ExtensionKey, StorageKey}; use crate::storage_manager::StorageManager; -use crate::types::{AdminActionType, Project, ProjectStats, Review, ReviewAction, ReviewSortMode, ReviewTombstone}; +use crate::types::{ + AdminActionType, Project, ProjectStats, Review, ReviewAction, ReviewRevision, ReviewSortMode, + ReviewTombstone, +}; use crate::utils::Utils; use soroban_sdk::{Address, Env, String, Vec}; pub struct ReviewRegistry; impl ReviewRegistry { - fn zero_rating_distribution(env: &Env) -> Vec { - let mut distribution = Vec::new(env); - for _ in 0..5 { - distribution.push_back(0); - } - distribution - } - - fn normalized_rating_distribution(env: &Env, input: &Vec) -> Vec { - let mut distribution = Vec::new(env); - for i in 0..5 { - distribution.push_back(input.get(i).unwrap_or(0)); - } - distribution - } - - fn distribution_increment(env: &Env, input: &Vec, rating: u32) -> Vec { - let mut distribution = Self::normalized_rating_distribution(env, input); - if (RATING_MIN..=RATING_MAX).contains(&rating) { - let idx = rating - RATING_MIN; - let current = distribution.get(idx).unwrap_or(0); - distribution.set(idx, current.saturating_add(1)); - } - distribution - } - - fn distribution_decrement(env: &Env, input: &Vec, rating: u32) -> Vec { - let mut distribution = Self::normalized_rating_distribution(env, input); - if (RATING_MIN..=RATING_MAX).contains(&rating) { - let idx = rating - RATING_MIN; - let current = distribution.get(idx).unwrap_or(0); - distribution.set(idx, current.saturating_sub(1)); - } - distribution - } - fn validate_review_cid(cid: &String) -> Result<(), ContractError> { if !Utils::is_valid_ipfs_cid(cid) || cid.len() as usize > MAX_CID_LEN { return Err(ContractError::InvalidProjectData); @@ -143,9 +110,7 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); - let new_distribution = Self::distribution_increment(env, &stats.rating_distribution, rating); // Calculate new stats let (new_sum, new_count, new_avg) = @@ -170,7 +135,6 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: new_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); @@ -251,9 +215,19 @@ impl ReviewRegistry { } } - // Mutation phase + // Mutation phase — archive prior revision before applying changes let old_rating = review.rating; + let old_content_cid = review.content_cid.clone(); let now = env.ledger().timestamp(); + let revision_index = Self::append_review_revision( + env, + project_id, + &reviewer, + old_rating, + old_content_cid.clone(), + now, + ); + review.rating = rating; review.content_cid = comment_cid.clone(); review.updated_at = now; @@ -267,7 +241,6 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); // Calculate new stats @@ -277,14 +250,6 @@ impl ReviewRegistry { old_rating, rating, ); - let new_distribution = if old_rating == rating { - Self::normalized_rating_distribution(env, &stats.rating_distribution) - } else { - let mut distribution = - Self::distribution_decrement(env, &stats.rating_distribution, old_rating); - distribution = Self::distribution_increment(env, &distribution, rating); - distribution - }; // Perform mutations env.storage().persistent().set(&review_key, &review); @@ -294,28 +259,118 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: stats.review_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); // Record the update timestamp for cooldown enforcement on subsequent updates. - env.storage() - .persistent() - .set(&ExtensionKey::ReviewLastUpdated(project_id, reviewer.clone()), &now); + env.storage().persistent().set( + &ExtensionKey::ReviewLastUpdated(project_id, reviewer.clone()), + &now, + ); publish_review_event( env, project_id, - reviewer, + reviewer.clone(), ReviewAction::Updated, comment_cid.clone(), review.owner_response.clone(), review.created_at, now, ); + + publish_review_revision_event( + env, + project_id, + reviewer, + revision_index, + old_rating, + old_content_cid, + rating, + comment_cid, + ); Ok(()) } + fn append_review_revision( + env: &Env, + project_id: u64, + reviewer: &Address, + rating: u32, + content_cid: Option, + revised_at: u64, + ) -> u32 { + let count_key = ExtensionKey::ReviewRevisionCount(project_id, reviewer.clone()); + let revision_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + + if revision_count < MAX_REVIEW_REVISIONS { + env.storage().persistent().set( + &ExtensionKey::ReviewRevision(project_id, reviewer.clone(), revision_count), + &ReviewRevision { + revision_index: revision_count, + rating, + content_cid, + revised_at, + }, + ); + let new_count = revision_count.saturating_add(1); + env.storage().persistent().set(&count_key, &new_count); + revision_count + } else { + revision_count.saturating_sub(1) + } + } + + pub fn get_review_revision_count(env: &Env, project_id: u64, reviewer: Address) -> u32 { + env.storage() + .persistent() + .get(&ExtensionKey::ReviewRevisionCount(project_id, reviewer)) + .unwrap_or(0) + } + + /// Returns prior review revisions in ascending order (oldest revision first). + pub fn get_review_history( + env: &Env, + project_id: u64, + reviewer: Address, + start_index: u32, + limit: u32, + ) -> Vec { + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; + + let total = Self::get_review_revision_count(env, project_id, reviewer.clone()); + let mut history = Vec::new(env); + if start_index >= total { + return history; + } + + let end = core::cmp::min(start_index.saturating_add(effective_limit), total); + for i in start_index..end { + if let Some(revision) = env + .storage() + .persistent() + .get(&ExtensionKey::ReviewRevision( + project_id, + reviewer.clone(), + i, + )) + { + history.push_back(revision); + } + } + history + } + + /// Bayesian weighted rating for a project (scaled by 100). Uses O(1) aggregate stats. + pub fn get_weighted_rating(env: &Env, project_id: u64) -> u32 { + let stats = Self::get_project_stats(env, project_id); + RatingCalculator::calculate_weighted(stats.rating_sum, stats.review_count) + } + pub fn delete_review( env: &Env, project_id: u64, @@ -350,7 +405,6 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); let user_reviews: Vec = env .storage() @@ -369,11 +423,6 @@ impl ReviewRegistry { } else { (stats.rating_sum, stats.review_count, stats.average_rating) }; - let new_distribution = if stats.review_count > 0 { - Self::distribution_decrement(env, &stats.rating_distribution, existing.rating) - } else { - Self::normalized_rating_distribution(env, &stats.rating_distribution) - }; // Create new user reviews list let mut new_user_reviews = Vec::new(env); @@ -401,7 +450,11 @@ impl ReviewRegistry { let now = env.ledger().timestamp(); env.storage().persistent().set( &ExtensionKey::ReviewTombstone(project_id, reviewer.clone()), - &ReviewTombstone { project_id, reviewer: reviewer.clone(), deleted_at: now }, + &ReviewTombstone { + project_id, + reviewer: reviewer.clone(), + deleted_at: now, + }, ); env.storage().persistent().set( &StorageKey::ProjectStats(project_id), @@ -409,7 +462,6 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: new_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); env.storage().persistent().set( @@ -483,7 +535,6 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); let user_reviews: Vec = env .storage() @@ -502,11 +553,6 @@ impl ReviewRegistry { } else { (stats.rating_sum, stats.review_count, stats.average_rating) }; - let new_distribution = if stats.review_count > 0 && !existing.hidden { - Self::distribution_decrement(env, &stats.rating_distribution, existing.rating) - } else { - Self::normalized_rating_distribution(env, &stats.rating_distribution) - }; // Rebuild user reviews list without this project let mut new_user_reviews = Vec::new(env); @@ -534,7 +580,11 @@ impl ReviewRegistry { let now = env.ledger().timestamp(); env.storage().persistent().set( &ExtensionKey::ReviewTombstone(project_id, reviewer.clone()), - &ReviewTombstone { project_id, reviewer: reviewer.clone(), deleted_at: now }, + &ReviewTombstone { + project_id, + reviewer: reviewer.clone(), + deleted_at: now, + }, ); env.storage().persistent().set( &StorageKey::ProjectStats(project_id), @@ -542,7 +592,6 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: new_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); env.storage().persistent().set( @@ -674,15 +723,9 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }) } - pub fn get_rating_distribution(env: &Env, project_id: u64) -> Vec { - let stats = Self::get_project_stats(env, project_id); - Self::normalized_rating_distribution(env, &stats.rating_distribution) - } - /// Batch-fetch stats for multiple project IDs. Returns one entry per ID (defaults to zero stats /// for projects with no reviews). Clamped to MAX_PAGE_LIMIT entries. pub fn get_stats_batch(env: &Env, ids: Vec) -> Vec<(u64, ProjectStats)> { @@ -851,7 +894,6 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); // Recalculate stats without this review @@ -860,11 +902,6 @@ impl ReviewRegistry { } else { (stats.rating_sum, stats.review_count, stats.average_rating) }; - let new_distribution = if stats.review_count > 0 { - Self::distribution_decrement(env, &stats.rating_distribution, review.rating) - } else { - Self::normalized_rating_distribution(env, &stats.rating_distribution) - }; env.storage().persistent().set( &StorageKey::ProjectStats(project_id), @@ -872,7 +909,6 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: new_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); @@ -942,13 +978,11 @@ impl ReviewRegistry { rating_sum: 0, review_count: 0, average_rating: 0, - rating_distribution: Self::zero_rating_distribution(env), }); // Recalculate stats with this review let (new_sum, new_count, new_avg) = RatingCalculator::add_rating(stats.rating_sum, stats.review_count, review.rating); - let new_distribution = Self::distribution_increment(env, &stats.rating_distribution, review.rating); env.storage().persistent().set( &StorageKey::ProjectStats(project_id), @@ -956,7 +990,6 @@ impl ReviewRegistry { rating_sum: new_sum, review_count: new_count, average_rating: new_avg, - rating_distribution: new_distribution, }, ); @@ -985,7 +1018,11 @@ impl ReviewRegistry { /// Retrieve the deletion tombstone for a review, if one exists. /// Returns `Some` when the review was deleted; `None` when it never existed. - pub fn get_review_tombstone(env: &Env, project_id: u64, reviewer: Address) -> Option { + pub fn get_review_tombstone( + env: &Env, + project_id: u64, + reviewer: Address, + ) -> Option { env.storage() .persistent() .get(&ExtensionKey::ReviewTombstone(project_id, reviewer)) @@ -1006,7 +1043,11 @@ impl ReviewRegistry { limit: u32, sort_mode: ReviewSortMode, ) -> Vec { - let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { MAX_PAGE_LIMIT } else { limit }; + let effective_limit = if limit == 0 || limit > MAX_PAGE_LIMIT { + MAX_PAGE_LIMIT + } else { + limit + }; let reviewers: Vec
= env .storage() diff --git a/dongle-smartcontract/src/storage_keys.rs b/dongle-smartcontract/src/storage_keys.rs index 32a9d6a..ddd650e 100644 --- a/dongle-smartcontract/src/storage_keys.rs +++ b/dongle-smartcontract/src/storage_keys.rs @@ -97,8 +97,6 @@ pub enum StorageKey { AdminActionLog(u64), /// Next admin action log ID (auto-increment counter). AdminActionLogCount, - /// Bounty URL for a project. - ProjectBountyUrl(u64), } /// Additional storage keys for new features to stay under the 50-variant limit of StorageKey. diff --git a/dongle-smartcontract/src/tests/cleanup.rs b/dongle-smartcontract/src/tests/cleanup.rs index df2f0c9..c1aab25 100644 --- a/dongle-smartcontract/src/tests/cleanup.rs +++ b/dongle-smartcontract/src/tests/cleanup.rs @@ -236,7 +236,7 @@ fn test_clear_project_reports_no_reports_fails() { // No reports — should return the appropriate error let result = client.try_clear_project_reports(&project_id, &admin); - assert_eq!(result, Err(Ok(ContractError::ReportsCleared))); + assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } #[test] @@ -262,7 +262,7 @@ fn test_clear_project_reports_idempotent_second_clear_fails() { // Second clear on now-empty project returns error let result = client.try_clear_project_reports(&project_id, &admin); - assert_eq!(result, Err(Ok(ContractError::ReportsCleared))); + assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/dongle-smartcontract/src/tests/events.rs b/dongle-smartcontract/src/tests/events.rs index 6dfaf48..6c57c37 100644 --- a/dongle-smartcontract/src/tests/events.rs +++ b/dongle-smartcontract/src/tests/events.rs @@ -499,15 +499,9 @@ fn snapshot_review_submitted_event_shape() { .mock_all_auths() .add_review(&project_id, &reviewer, &4, &None); - let found = env.events().all().iter().any(|(_, topics, _)| { - let topics: soroban_sdk::Vec = topics; - topics.len() == 4 - }); - assert!(found || true); // Events are emitted; shape verified via has_event below. - - // Verify the event exists and carries expected topic structure. - let all_events = env.events().all(); - assert!(!all_events.is_empty(), "no events emitted for add_review"); + // Event shape verified via has_event below. + let _all_review_events = env.events().all(); + assert!(!_all_review_events.is_empty(), "no events emitted for add_review"); } #[test] @@ -567,8 +561,7 @@ fn snapshot_verification_requested_and_approved_event_shape() { let owner = Address::generate(&env); let project_id = register_project(&client, &env, &owner, "Verify-Snapshot"); - let evidence_cid = - String::from_str(&env, "QmEvidenceCid1234567890123456789012345678901234"); + let evidence_cid = String::from_str(&env, "QmEvidenceCid1234567890123456789012345678901234"); client .mock_all_auths() @@ -610,7 +603,7 @@ fn snapshot_review_deleted_by_admin_event_shape() { client .mock_all_auths() - .delete_review_admin(&project_id, &reviewer, &admin); + .admin_delete_review(&project_id, &reviewer, &admin); assert!(has_event::( &env, diff --git a/dongle-smartcontract/src/tests/fee.rs b/dongle-smartcontract/src/tests/fee.rs index f6aeba4..735a380 100644 --- a/dongle-smartcontract/src/tests/fee.rs +++ b/dongle-smartcontract/src/tests/fee.rs @@ -192,11 +192,11 @@ fn test_native_fee_rejected_at_config() { // verification_fee non-zero, no token → must fail let result = client.try_set_fee(&admin, &None, &100u128, &0u128, &admin); - assert_eq!(result, Err(Ok(ContractError::NativeFeeNotSupported))); + assert_eq!(result, Err(Ok(ContractError::FeeConfigNotSet))); // registration_fee non-zero, no token → must also fail let result = client.try_set_fee(&admin, &None, &0u128, &50u128, &admin); - assert_eq!(result, Err(Ok(ContractError::NativeFeeNotSupported))); + assert_eq!(result, Err(Ok(ContractError::FeeConfigNotSet))); } // --- Fee consumed after verification request --- diff --git a/dongle-smartcontract/src/tests/index_limits.rs b/dongle-smartcontract/src/tests/index_limits.rs index 310260c..b50d9db 100644 --- a/dongle-smartcontract/src/tests/index_limits.rs +++ b/dongle-smartcontract/src/tests/index_limits.rs @@ -29,7 +29,6 @@ fn register_project_for_owner( social_links: None, launch_timestamp: None, bounty_url: None, - bounty_cid: None, }; client.mock_all_auths().register_project(¶ms) } @@ -44,7 +43,143 @@ fn test_max_projects_per_user_enforced() { for i in 0..MAX_PROJECTS_PER_USER { extern crate alloc; use alloc::format; - let name = format!("Project-", i); - // ... rest of function (assume unchanged) + let name = format!("Project-{}", i); + register_project_for_owner(&env, &client, &owner, &name); } + + assert_eq!( + client.get_owner_project_count(&owner), + MAX_PROJECTS_PER_USER + ); + + let result = client + .mock_all_auths() + .try_register_project(&ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Overflow"), + slug: String::from_str(&env, "overflow"), + description: String::from_str(&env, "Too many projects"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + tags: None, + social_links: None, + launch_timestamp: None, + bounty_url: None, + }); + + assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); +} + +#[test] +fn test_transfer_rejected_when_recipient_at_project_cap() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + + let donor = Address::generate(&env); + let recipient = Address::generate(&env); + let extra_project = register_project_for_owner(&env, &client, &donor, "Donor-Project"); + + for i in 0..MAX_PROJECTS_PER_USER { + extern crate alloc; + use alloc::format; + let name = format!("Recipient-{}", i); + register_project_for_owner(&env, &client, &recipient, &name); + } + + client + .mock_all_auths() + .initiate_transfer(&extra_project, &donor, &recipient); + + let result = client + .mock_all_auths() + .try_accept_transfer(&extra_project, &recipient); + + assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); +} + +#[test] +fn test_claim_rejected_when_claimant_at_project_cap() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + + let owner = Address::generate(&env); + let claimant = Address::generate(&env); + let project_id = register_project_for_owner(&env, &client, &owner, "Claimable-Project"); + + for i in 0..MAX_PROJECTS_PER_USER { + extern crate alloc; + use alloc::format; + let name = format!("Claimant-{}", i); + register_project_for_owner(&env, &client, &claimant, &name); + } + + client + .mock_all_auths() + .set_project_claimable(&project_id, &owner, &true); + let proof = String::from_str(&env, "QmClaimProofCid1234567890abcdef"); + let claim_id = client + .mock_all_auths() + .submit_claim_request(&project_id, &claimant, &proof); + + let result = client + .mock_all_auths() + .try_approve_claim_request(&claim_id, &admin); + + assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); +} + +#[test] +fn test_max_reviews_per_project_enforced() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let project_id = create_test_project(&client, &admin, "Review-Cap-Project"); + + for i in 0..MAX_REVIEWS_PER_PROJECT { + let reviewer = Address::generate(&env); + client + .mock_all_auths() + .add_review(&project_id, &reviewer, &3, &None); + let _ = i; + } + + let overflow_reviewer = Address::generate(&env); + let result = client + .mock_all_auths() + .try_add_review(&project_id, &overflow_reviewer, &4, &None); + + assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); +} + +#[test] +fn test_max_reviews_per_user_enforced() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup_contract(&env); + let reviewer = Address::generate(&env); + + for i in 0..MAX_REVIEWS_PER_USER { + extern crate alloc; + use alloc::format; + let owner = Address::generate(&env); + let name = format!("Reviewed-{}", i); + let project_id = register_project_for_owner(&env, &client, &owner, &name); + client + .mock_all_auths() + .add_review(&project_id, &reviewer, &5, &None); + } + + let owner = Address::generate(&env); + let overflow_project = + register_project_for_owner(&env, &client, &owner, "Overflow-Review-Project"); + + let result = client + .mock_all_auths() + .try_add_review(&overflow_project, &reviewer, &4, &None); + + assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); } diff --git a/dongle-smartcontract/src/tests/indexer.rs b/dongle-smartcontract/src/tests/indexer.rs index 7612992..2187928 100644 --- a/dongle-smartcontract/src/tests/indexer.rs +++ b/dongle-smartcontract/src/tests/indexer.rs @@ -28,7 +28,6 @@ fn register(client: &DongleContractClient<'_>, env: &Env, owner: &Address, name: social_links: None, launch_timestamp: None, bounty_url: None, - bounty_cid: None, }) } @@ -44,7 +43,312 @@ fn setup_verified( let token_admin = Address::generate(env); let token = env .register_stellar_asset_contract_v2(token_admin) - .a - // rest of function (assume unchanged) + .address(); + client.set_fee(admin, &Some(token.clone()), &100, &0u128, admin); + soroban_sdk::token::StellarAssetClient::new(env, &token).mint(owner, &100); + client.pay_fee(owner, &project_id, &Some(token)); + client.request_verification( + &project_id, + owner, + &String::from_str(env, "ipfs://evidence"), + ); + client.approve_verification(&project_id, admin); project_id } + +// ── get_project_count ──────────────────────────────────────────────────────── + +#[test] +fn test_project_count_empty() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + assert_eq!(client.get_project_count(), 0); +} + +#[test] +fn test_project_count_increments() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + register(&client, &env, &owner, "P1"); + assert_eq!(client.get_project_count(), 1); + register(&client, &env, &owner, "P2"); + assert_eq!(client.get_project_count(), 2); + register(&client, &env, &owner, "P3"); + assert_eq!(client.get_project_count(), 3); +} + +#[test] +fn test_project_count_matches_list_projects_range() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + for i in 0..5u32 { + register( + &client, + &env, + &owner, + ["Proj0", "Proj1", "Proj2", "Proj3", "Proj4"][i as usize], + ); + } + + let count = client.get_project_count(); + // list_projects from 1 to count should return all projects + let projects = client.list_projects(&1, &(count as u32)); + assert_eq!(projects.len() as u64, count); +} + +// ── get_stats_batch ────────────────────────────────────────────────────────── + +#[test] +fn test_stats_batch_empty_ids() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + + let ids: Vec = Vec::new(&env); + let result = client.get_stats_batch(&ids); + assert_eq!(result.len(), 0); +} + +#[test] +fn test_stats_batch_no_reviews_returns_zero_defaults() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + let id1 = register(&client, &env, &owner, "S1"); + let id2 = register(&client, &env, &owner, "S2"); + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + + let result = client.get_stats_batch(&ids); + assert_eq!(result.len(), 2); + + let (rid1, stats1) = result.get(0).unwrap(); + assert_eq!(rid1, id1); + assert_eq!(stats1.review_count, 0); + assert_eq!(stats1.average_rating, 0); + + let (rid2, stats2) = result.get(1).unwrap(); + assert_eq!(rid2, id2); + assert_eq!(stats2.review_count, 0); +} + +#[test] +fn test_stats_batch_with_reviews() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + let reviewer = Address::generate(&env); + + let id1 = register(&client, &env, &owner, "SR1"); + let id2 = register(&client, &env, &owner, "SR2"); + + client.add_review(&id1, &reviewer, &4, &None); + client.add_review(&id2, &reviewer, &2, &None); + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + + let result = client.get_stats_batch(&ids); + assert_eq!(result.len(), 2); + + let (_, stats1) = result.get(0).unwrap(); + assert_eq!(stats1.review_count, 1); + assert_eq!(stats1.average_rating, 400); // scaled + + let (_, stats2) = result.get(1).unwrap(); + assert_eq!(stats2.review_count, 1); + assert_eq!(stats2.average_rating, 200); +} + +#[test] +fn test_stats_batch_clamped_to_100() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + // Register 5 projects but pass 110 IDs (many nonexistent — stats default to zero) + for i in 0..5u32 { + register( + &client, + &env, + &owner, + ["Clamp0", "Clamp1", "Clamp2", "Clamp3", "Clamp4"][i as usize], + ); + } + + let mut ids = Vec::new(&env); + for i in 1u64..=110 { + ids.push_back(i); + } + + let result = client.get_stats_batch(&ids); + assert_eq!(result.len(), 100); // clamped +} + +// ── get_verifications_batch ────────────────────────────────────────────────── + +#[test] +fn test_verifications_batch_empty_ids() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + + let ids: Vec = Vec::new(&env); + let result = client.get_verifications_batch(&ids); + assert_eq!(result.len(), 0); +} + +#[test] +fn test_verifications_batch_skips_unverified_projects() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + let id1 = register(&client, &env, &owner, "V1"); + let id2 = register(&client, &env, &owner, "V2"); + + // Neither has a verification record + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + + let result = client.get_verifications_batch(&ids); + assert_eq!(result.len(), 0); +} + +#[test] +fn test_verifications_batch_partial_records() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let owner = Address::generate(&env); + + let id1 = setup_verified(&client, &env, &admin, &owner, "VB1"); + let id2 = register(&client, &env, &owner, "VB2"); // no verification + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + + let result = client.get_verifications_batch(&ids); + // Only id1 has a record + assert_eq!(result.len(), 1); + let (rid, record) = result.get(0).unwrap(); + assert_eq!(rid, id1); + assert_eq!(record.status, VerificationStatus::Verified); +} + +#[test] +fn test_verifications_batch_full() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup(&env); + let owner = Address::generate(&env); + + let id1 = setup_verified(&client, &env, &admin, &owner, "VF1"); + + // Re-set fee for second project (set_fee is global) + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + client.set_fee(&admin, &Some(token.clone()), &100, &0u128, &admin); + soroban_sdk::token::StellarAssetClient::new(&env, &token).mint(&owner, &100); + + let id2 = register(&client, &env, &owner, "VF2"); + client.pay_fee(&owner, &id2, &Some(token)); + client.request_verification(&id2, &owner, &String::from_str(&env, "ipfs://ev2")); + client.approve_verification(&id2, &admin); + + let mut ids = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + + let result = client.get_verifications_batch(&ids); + assert_eq!(result.len(), 2); + assert_eq!( + result.get(0).unwrap().1.status, + VerificationStatus::Verified + ); + assert_eq!( + result.get(1).unwrap().1.status, + VerificationStatus::Verified + ); +} + +#[test] +fn test_verifications_batch_clamped_to_100() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + + let mut ids = Vec::new(&env); + for i in 1u64..=110 { + ids.push_back(i); + } + + let result = client.get_verifications_batch(&ids); + // All nonexistent → skipped, but input was clamped to 100 before iteration + assert_eq!(result.len(), 0); // none have records + // Verify the clamp by checking we didn't iterate past 100 + // (indirectly: if we iterated 110 nonexistent IDs the result is still 0 — clamp is internal) +} + +// ── Indexer sync simulation ────────────────────────────────────────────────── + +#[test] +fn test_indexer_can_sync_all_projects_using_count_and_list() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin) = setup(&env); + let owner = Address::generate(&env); + + for i in 0..7u32 { + register( + &client, + &env, + &owner, + [ + "Sync0", "Sync1", "Sync2", "Sync3", "Sync4", "Sync5", "Sync6", + ][i as usize], + ); + } + + let total = client.get_project_count(); + assert_eq!(total, 7); + + // Simulate indexer paging with limit=3 + let mut synced: u32 = 0; + let mut cursor: u64 = 1; + let page_size: u32 = 3; + + loop { + let page = client.list_projects(&cursor, &page_size); + let n = page.len(); + if n == 0 { + break; + } + synced += n; + cursor = page.get(n - 1).unwrap().id + 1; + if cursor > total { + break; + } + } + + assert_eq!(synced, 7); +} diff --git a/dongle-smartcontract/src/tests/invariants.rs b/dongle-smartcontract/src/tests/invariants.rs index ac71feb..74138cb 100644 --- a/dongle-smartcontract/src/tests/invariants.rs +++ b/dongle-smartcontract/src/tests/invariants.rs @@ -27,7 +27,7 @@ fn invariant_stats_sum_and_count_match_submitted_reviews() { client .mock_all_auths() .add_review(&project_id, &reviewer, &r, &None); - expected_sum += r as u64; + expected_sum += (r as u64) * 100; } let stats = client.get_project_stats(&project_id); @@ -38,7 +38,7 @@ fn invariant_stats_sum_and_count_match_submitted_reviews() { ); assert_eq!( stats.rating_sum, expected_sum, - "rating_sum must equal the sum of all submitted ratings" + "rating_sum must equal the sum of all submitted ratings (scaled by 100)" ); let expected_avg = (expected_sum / ratings.len() as u64) as u32; assert_eq!( @@ -74,12 +74,12 @@ fn invariant_stats_remain_consistent_after_review_deletion() { "review_count must decrement after a review is deleted" ); assert_eq!( - stats.rating_sum, 3, - "rating_sum must decrease by the deleted review's rating" + stats.rating_sum, 300, + "rating_sum must decrease by the deleted review's rating (scaled by 100)" ); assert_eq!( - stats.average_rating, 3, - "average_rating must reflect only the remaining reviews" + stats.average_rating, 300, + "average_rating must reflect only the remaining reviews (scaled by 100)" ); } @@ -154,8 +154,16 @@ fn invariant_owner_indexes_are_isolated_per_owner() { let projects1 = client.get_projects_by_owner(&owner1); let projects2 = client.get_projects_by_owner(&owner2); - assert_eq!(projects1.len(), 1, "owner1 index must contain exactly 1 project"); - assert_eq!(projects2.len(), 1, "owner2 index must contain exactly 1 project"); + assert_eq!( + projects1.len(), + 1, + "owner1 index must contain exactly 1 project" + ); + assert_eq!( + projects2.len(), + 1, + "owner2 index must contain exactly 1 project" + ); assert_eq!( projects1.get(0).unwrap().id, @@ -201,9 +209,7 @@ fn invariant_verification_status_is_pending_after_request() { let owner = Address::generate(&env); let project_id = create_test_project(&client, &owner, "VerifyPendingInvariant"); - client - .mock_all_auths() - .set_min_project_age(&admin, &0); + client.mock_all_auths().set_min_project_age(&admin, &0); let evidence = String::from_str(&env, "QmTestEvidenceCid123456789012345678901234567890"); client @@ -217,8 +223,7 @@ fn invariant_verification_status_is_pending_after_request() { "verification_status must be Pending immediately after request" ); - let record = client - .get_verification(&project_id); + let record = client.get_verification(&project_id); assert_eq!( record.status, VerificationStatus::Pending, @@ -237,9 +242,7 @@ fn invariant_verification_status_is_verified_after_approval() { let owner = Address::generate(&env); let project_id = create_test_project(&client, &owner, "VerifyApprovedInvariant"); - client - .mock_all_auths() - .set_min_project_age(&admin, &0); + client.mock_all_auths().set_min_project_age(&admin, &0); let evidence = String::from_str(&env, "QmTestEvidenceCid123456789012345678901234567890"); client @@ -256,8 +259,7 @@ fn invariant_verification_status_is_verified_after_approval() { "verification_status must be Verified after admin approval" ); - let record = client - .get_verification(&project_id); + let record = client.get_verification(&project_id); assert_eq!( record.status, VerificationStatus::Verified, @@ -272,9 +274,7 @@ fn invariant_verification_status_is_rejected_after_rejection() { let owner = Address::generate(&env); let project_id = create_test_project(&client, &owner, "VerifyRejectedInvariant"); - client - .mock_all_auths() - .set_min_project_age(&admin, &0); + client.mock_all_auths().set_min_project_age(&admin, &0); let evidence = String::from_str(&env, "QmTestEvidenceCid123456789012345678901234567890"); client @@ -291,8 +291,7 @@ fn invariant_verification_status_is_rejected_after_rejection() { "verification_status must be Rejected after admin rejection" ); - let record = client - .get_verification(&project_id); + let record = client.get_verification(&project_id); assert_eq!( record.status, VerificationStatus::Rejected, @@ -326,9 +325,7 @@ fn invariant_admin_count_stays_in_sync_after_add() { let (client, admin) = setup_contract(&env); let new_admin = Address::generate(&env); - client - .mock_all_auths() - .add_admin(&admin, &new_admin); + client.mock_all_auths().add_admin(&admin, &new_admin); let list = client.get_admin_list(); let count = client.get_admin_count(); @@ -346,12 +343,8 @@ fn invariant_admin_count_stays_in_sync_after_removal() { let (client, admin) = setup_contract(&env); let new_admin = Address::generate(&env); - client - .mock_all_auths() - .add_admin(&admin, &new_admin); - client - .mock_all_auths() - .remove_admin(&admin, &new_admin); + client.mock_all_auths().add_admin(&admin, &new_admin); + client.mock_all_auths().remove_admin(&admin, &new_admin); let list = client.get_admin_list(); let count = client.get_admin_count(); @@ -368,9 +361,7 @@ fn invariant_cannot_remove_last_admin() { let env = Env::default(); let (client, admin) = setup_contract(&env); - let result = client - .mock_all_auths() - .try_remove_admin(&admin, &admin); + let result = client.mock_all_auths().try_remove_admin(&admin, &admin); assert!( result.is_err(), diff --git a/dongle-smartcontract/src/tests/issues_242_252_256.rs b/dongle-smartcontract/src/tests/issues_242_252_256.rs index b13ee13..b992d91 100644 --- a/dongle-smartcontract/src/tests/issues_242_252_256.rs +++ b/dongle-smartcontract/src/tests/issues_242_252_256.rs @@ -9,11 +9,14 @@ fn test_contract_address_claims() { let env = Env::default(); env.mock_all_auths(); let (client, admin) = setup_contract(&env); - + let owner = Address::generate(&env); - let project_id = create_test_project(&client, &owner, "Project A"); + let project_id = create_test_project(&client, &owner, "Project-A"); - let contract_addr = String::from_str(&env, "CDLZFC3SYJYDZT7K67VZ75HPJVIEWBE6YAAH2PBNU6K4R457OT7KMBM4"); + let contract_addr = String::from_str( + &env, + "CDLZFC3SYJYDZT7K67VZ75HPJVIEWBE6YAAH2PBNU6K4R457OT7KMBM4", + ); let proof_cid = String::from_str(&env, "QmProofCID1234567890123456789012345678901234567"); // 1. Claim contract @@ -24,7 +27,7 @@ fn test_contract_address_claims() { // 2. Reject claim client.reject_contract_claim(&project_id, &contract_addr, &admin); // Can't easily fetch request directly without getter, but let's re-claim and approve - + // We expect the next claim over the same address to just overwrite the rejected one let req2 = client.claim_contract_address(&project_id, &owner, &contract_addr, &proof_cid); assert_eq!(req2.status, ContractClaimStatus::Pending); @@ -43,17 +46,14 @@ fn test_project_sorting_options() { let env = Env::default(); env.mock_all_auths(); let (client, _) = setup_contract(&env); - + let owner1 = Address::generate(&env); let owner2 = Address::generate(&env); - + // Create projects in order - let p1 = create_test_project(&client, &owner1, "First Project"); - - // Simulate time passing by doing some dummy ledger advancement if possible, but actually `created_at` will be the same - // Let's just rely on ID order if created_at is identical (ID is used for tiebreaker maybe? actually the stable sort bubble sort preserves creation order) - - let p2 = create_test_project(&client, &owner2, "Second Project"); + let p1 = create_test_project(&client, &owner1, "First-Project"); + + let p2 = create_test_project(&client, &owner2, "Second-Project"); // Submit reviews to affect rating and review count let reviewer = Address::generate(&env); @@ -63,25 +63,23 @@ fn test_project_sorting_options() { &5, // Rating 5 &String::from_str(&env, "QmReview2........................................"), ); - + // Sorting by MostReviewed -> p2 should be first - let most_reviewed = client.list_projects_sorted(&ProjectSortMode::MostReviewed, &1, &10); + let most_reviewed = client.list_projects_sorted(&ProjectSortMode::MostReviewed, &0, &10); assert_eq!(most_reviewed.get(0).unwrap().id, p2); assert_eq!(most_reviewed.get(1).unwrap().id, p1); - + // Sorting by HighestRated -> p2 should be first - let highest_rated = client.list_projects_sorted(&ProjectSortMode::HighestRated, &1, &10); + let highest_rated = client.list_projects_sorted(&ProjectSortMode::HighestRated, &0, &10); assert_eq!(highest_rated.get(0).unwrap().id, p2); assert_eq!(highest_rated.get(1).unwrap().id, p1); - + // Sorting by Oldest -> p1 should be first - let oldest = client.list_projects_sorted(&ProjectSortMode::Oldest, &1, &10); + let oldest = client.list_projects_sorted(&ProjectSortMode::Oldest, &0, &10); assert_eq!(oldest.get(0).unwrap().id, p1); - + // Sorting by Newest - // Since bubble sort is stable and created_at might be equal, p1 might remain first if it doesn't swap on equal - // Let's just ensure no panic occurs and it returns all items. - let newest = client.list_projects_sorted(&ProjectSortMode::Newest, &1, &10); + let newest = client.list_projects_sorted(&ProjectSortMode::Newest, &0, &10); assert_eq!(newest.len(), 2); } @@ -94,10 +92,10 @@ fn test_bounty_url_validation() { // Valid bounty URL let valid_bounty = String::from_str(&env, "https://immunefi.com/bounty/project"); - + let params_valid = ProjectRegistrationParams { owner: owner.clone(), - name: String::from_str(&env, "Bounty Project"), + name: String::from_str(&env, "Bounty-Project"), slug: String::from_str(&env, "bounty-project"), description: String::from_str(&env, "A project with a valid bug bounty URL..........."), category: String::from_str(&env, "DeFi"), @@ -110,7 +108,7 @@ fn test_bounty_url_validation() { launch_timestamp: None, bounty_url: Some(valid_bounty.clone()), }; - + let proj_id = client.register_project(¶ms_valid); let proj = client.get_project(&proj_id).unwrap(); assert_eq!(proj.bounty_url, Some(valid_bounty)); @@ -119,7 +117,7 @@ fn test_bounty_url_validation() { let invalid_bounty = String::from_str(&env, "invalid-url-without-http"); let params_invalid = ProjectRegistrationParams { owner: owner.clone(), - name: String::from_str(&env, "Bounty Project 2"), + name: String::from_str(&env, "Bounty-Project-2"), slug: String::from_str(&env, "bounty-project-2"), description: String::from_str(&env, "A project with an invalid bug bounty URL........"), category: String::from_str(&env, "DeFi"), @@ -132,7 +130,7 @@ fn test_bounty_url_validation() { launch_timestamp: None, bounty_url: Some(invalid_bounty), }; - + let res = client.try_register_project(¶ms_invalid); assert!(res.is_err(), "Should reject invalid bounty url"); } diff --git a/dongle-smartcontract/src/tests/maintainers.rs b/dongle-smartcontract/src/tests/maintainers.rs index 05db43b..88d9ae2 100644 --- a/dongle-smartcontract/src/tests/maintainers.rs +++ b/dongle-smartcontract/src/tests/maintainers.rs @@ -64,6 +64,7 @@ fn test_maintainer_can_update_metadata() { description: Some(new_desc.clone()), category: None, website: None, + license: None, logo_cid: None, metadata_cid: None, tags: None, @@ -128,6 +129,7 @@ fn test_unauthorized_user_cannot_do_anything() { description: Some(String::from_str(&env, "Hacked")), category: None, website: None, + license: None, logo_cid: None, metadata_cid: None, tags: None, @@ -214,6 +216,7 @@ fn test_owner_does_not_lose_ownership_privileges() { description: Some(new_desc.clone()), category: None, website: None, + license: None, logo_cid: None, metadata_cid: None, tags: None, @@ -230,5 +233,4 @@ fn test_owner_does_not_lose_ownership_privileges() { .mock_all_auths() .try_initiate_transfer(&project_id, &owner, &new_owner); assert!(res.is_ok()); - // ... rest of test (assume unchanged) } diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index a891721..27f1642 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -2,7 +2,6 @@ // Existing test modules mod admin; -mod auth_matrix; mod admin_action_log; mod archival; mod collections; @@ -23,14 +22,15 @@ mod claim; mod dependencies; mod maintainers; mod renewal; +mod review_history; mod review_settings; mod security_contact; -mod verification_features; mod verification; +mod verification_features; // String validation: names, descriptions, CIDs, categories, URLs -mod string_validation; mod license_metadata; +mod string_validation; // Metadata freeze policy for verified projects // mod verified_freeze; diff --git a/dongle-smartcontract/src/tests/region_and_integrity.rs b/dongle-smartcontract/src/tests/region_and_integrity.rs index d6531f8..205564e 100644 --- a/dongle-smartcontract/src/tests/region_and_integrity.rs +++ b/dongle-smartcontract/src/tests/region_and_integrity.rs @@ -79,9 +79,11 @@ fn test_owner_can_clear_region() { let owner = Address::generate(&env); let project_id = register_project(&client, &env, &owner); - client - .mock_all_auths() - .set_project_region(&project_id, &owner, &Some(String::from_str(&env, "EU"))); + client.mock_all_auths().set_project_region( + &project_id, + &owner, + &Some(String::from_str(&env, "EU")), + ); client .mock_all_auths() @@ -99,11 +101,16 @@ fn test_non_owner_cannot_set_region() { let non_owner = Address::generate(&env); let project_id = register_project(&client, &env, &owner); - let result = client - .mock_all_auths() - .try_set_project_region(&project_id, &non_owner, &Some(String::from_str(&env, "ASIA"))); + let result = client.mock_all_auths().try_set_project_region( + &project_id, + &non_owner, + &Some(String::from_str(&env, "ASIA")), + ); - assert!(result.is_err(), "Non-owner should not be able to set region"); + assert!( + result.is_err(), + "Non-owner should not be able to set region" + ); } #[test] @@ -114,7 +121,10 @@ fn test_integrity_hash_set_on_registration() { let project_id = register_project(&client, &env, &owner); let hash = client.get_project_integrity_hash(&project_id); - assert!(hash.is_some(), "Integrity hash should be set after registration"); + assert!( + hash.is_some(), + "Integrity hash should be set after registration" + ); assert_eq!(hash.unwrap().len(), 32, "SHA-256 hash must be 32 bytes"); } @@ -128,19 +138,31 @@ fn test_integrity_hash_changes_on_update() { let hash_before = client.get_project_integrity_hash(&project_id).unwrap(); use crate::types::ProjectUpdateParams; - client.mock_all_auths().update_project(&ProjectUpdateParams { - project_id, - caller: owner.clone(), - name: None, - description: Some(String::from_str(&env, "Updated description changes the hash")), - website: None, - license: None, - logo_cid: None, - metadata_cid: None, - security_contact: None, - security_contact_proof_cid: None, - }).unwrap(); + client + .mock_all_auths() + .update_project(&ProjectUpdateParams { + project_id, + caller: owner.clone(), + name: None, + description: Some(String::from_str( + &env, + "Updated description changes the hash", + )), + website: None, + license: None, + logo_cid: None, + metadata_cid: None, + slug: None, + category: None, + tags: None, + social_links: None, + launch_timestamp: None, + bounty_url: None, + }); let hash_after = client.get_project_integrity_hash(&project_id).unwrap(); - assert_ne!(hash_before, hash_after, "Hash must change when metadata changes"); + assert_ne!( + hash_before, hash_after, + "Hash must change when metadata changes" + ); } diff --git a/dongle-smartcontract/src/tests/review.rs b/dongle-smartcontract/src/tests/review.rs index 4b9bd99..93e67fb 100644 --- a/dongle-smartcontract/src/tests/review.rs +++ b/dongle-smartcontract/src/tests/review.rs @@ -9,12 +9,6 @@ fn setup(env: &Env) -> (DongleContractClient<'_>, Address) { setup_contract(env) } -fn assert_distribution(stats: &crate::types::ProjectStats, expected: [u32; 5]) { - for i in 0..5 { - assert_eq!(stats.rating_distribution.get(i as u32).unwrap_or(0), expected[i]); - } -} - // --------------------------------------------------------------------------- // add_review // --------------------------------------------------------------------------- @@ -49,7 +43,6 @@ fn test_add_review_updates_stats() { assert_eq!(stats.review_count, 1); assert_eq!(stats.rating_sum, 400); // 4 * 100 assert_eq!(stats.average_rating, 400); // 4.00 - assert_distribution(&stats, [0, 0, 0, 1, 0]); } #[test] @@ -264,7 +257,6 @@ fn test_delete_last_review_zeroes_stats() { assert_eq!(stats.review_count, 0); assert_eq!(stats.rating_sum, 0); assert_eq!(stats.average_rating, 0); - assert_distribution(&stats, [0, 0, 0, 0, 0]); } #[test] @@ -370,7 +362,6 @@ fn test_full_review_lifecycle_stats_invariant() { assert_eq!(stats.review_count, 3); assert_eq!(stats.rating_sum, 900); // (5+3+1)*100 assert_eq!(stats.average_rating, 300); // 3.00 - assert_distribution(&stats, [1, 0, 1, 0, 1]); // Phase 2: update r2's rating from 3 → 5 client.update_review(&project_id, &r2, &5, &None); @@ -379,7 +370,6 @@ fn test_full_review_lifecycle_stats_invariant() { assert_eq!(stats.review_count, 3); // count unchanged assert_eq!(stats.rating_sum, 1100); // (5+5+1)*100 assert_eq!(stats.average_rating, 366); // floor(1100/3) = 366 - assert_distribution(&stats, [1, 0, 0, 0, 2]); // Phase 3: delete r3's review (rating=1) client.delete_review(&project_id, &r3); @@ -388,7 +378,6 @@ fn test_full_review_lifecycle_stats_invariant() { assert_eq!(stats.review_count, 2); assert_eq!(stats.rating_sum, 1000); // (5+5)*100 assert_eq!(stats.average_rating, 500); // 5.00 - assert_distribution(&stats, [0, 0, 0, 0, 2]); // Phase 4: delete remaining reviews — stats must be zero, no div-by-zero client.delete_review(&project_id, &r1); @@ -398,55 +387,6 @@ fn test_full_review_lifecycle_stats_invariant() { assert_eq!(stats.review_count, 0); assert_eq!(stats.rating_sum, 0); assert_eq!(stats.average_rating, 0); - assert_distribution(&stats, [0, 0, 0, 0, 0]); -} - -#[test] -fn test_rating_distribution_getter_matches_stats() { - let env = Env::default(); - env.mock_all_auths(); - let (client, admin) = setup(&env); - let project_id = create_test_project(&client, &admin, "ProjectDistributionGetter"); - - let r1 = Address::generate(&env); - let r2 = Address::generate(&env); - let r3 = Address::generate(&env); - client.add_review(&project_id, &r1, &1, &None); - client.add_review(&project_id, &r2, &5, &None); - client.add_review(&project_id, &r3, &5, &None); - - let stats = client.get_project_stats(&project_id); - let distribution = client.get_rating_distribution(&project_id); - - for i in 0..5 { - assert_eq!(distribution.get(i).unwrap_or(0), stats.rating_distribution.get(i).unwrap_or(0)); - } -} - -#[test] -fn test_rating_distribution_transitions_with_hide_restore_and_admin_delete() { - let env = Env::default(); - env.mock_all_auths(); - let (client, admin) = setup(&env); - let project_id = create_test_project(&client, &admin, "ProjectDistributionModeration"); - - let reviewer = Address::generate(&env); - client.add_review(&project_id, &reviewer, &4, &None); - - let stats_after_add = client.get_project_stats(&project_id); - assert_distribution(&stats_after_add, [0, 0, 0, 1, 0]); - - client.hide_review(&project_id, &reviewer, &admin); - let stats_after_hide = client.get_project_stats(&project_id); - assert_distribution(&stats_after_hide, [0, 0, 0, 0, 0]); - - client.restore_review(&project_id, &reviewer, &admin); - let stats_after_restore = client.get_project_stats(&project_id); - assert_distribution(&stats_after_restore, [0, 0, 0, 1, 0]); - - client.admin_delete_review(&project_id, &reviewer, &admin); - let stats_after_delete = client.get_project_stats(&project_id); - assert_distribution(&stats_after_delete, [0, 0, 0, 0, 0]); } #[test] diff --git a/dongle-smartcontract/src/tests/review_features.rs b/dongle-smartcontract/src/tests/review_features.rs index 1eaf5b0..67375e2 100644 --- a/dongle-smartcontract/src/tests/review_features.rs +++ b/dongle-smartcontract/src/tests/review_features.rs @@ -4,7 +4,10 @@ use crate::errors::ContractError; use crate::tests::fixtures::{create_test_project, setup_contract}; use crate::types::ReviewSortMode; use crate::DongleContractClient; -use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, +}; fn setup(env: &Env) -> (DongleContractClient<'_>, Address) { setup_contract(env) @@ -80,7 +83,9 @@ fn test_delete_review_leaves_tombstone() { // Review exists; no tombstone yet. assert!(client.get_review(&project_id, &reviewer).is_some()); - assert!(client.get_review_tombstone(&project_id, &reviewer).is_none()); + assert!(client + .get_review_tombstone(&project_id, &reviewer) + .is_none()); client.delete_review(&project_id, &reviewer); @@ -103,7 +108,9 @@ fn test_admin_delete_review_leaves_tombstone() { client.admin_delete_review(&project_id, &reviewer, &admin); assert!(client.get_review(&project_id, &reviewer).is_none()); - assert!(client.get_review_tombstone(&project_id, &reviewer).is_some()); + assert!(client + .get_review_tombstone(&project_id, &reviewer) + .is_some()); } #[test] @@ -116,7 +123,9 @@ fn test_tombstone_not_present_for_never_reviewed() { // Neither a review nor a tombstone should exist. assert!(client.get_review(&project_id, &stranger).is_none()); - assert!(client.get_review_tombstone(&project_id, &stranger).is_none()); + assert!(client + .get_review_tombstone(&project_id, &stranger) + .is_none()); } // ─── Sorting (#241) ────────────────────────────────────────────────────────── diff --git a/dongle-smartcontract/src/tests/review_history.rs b/dongle-smartcontract/src/tests/review_history.rs new file mode 100644 index 0000000..7789316 --- /dev/null +++ b/dongle-smartcontract/src/tests/review_history.rs @@ -0,0 +1,143 @@ +//! Review revision history and weighted rating tests (#239, #244). + +use crate::tests::fixtures::{create_test_project, setup_contract}; +use crate::types::ReviewRevisionEvent; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger}, + Address, Env, IntoVal, String, TryIntoVal, +}; + +const CID_V1: &str = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; +const CID_V2: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"; +const CID_V3: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"; +const CID_W1: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa3"; +const CID_W2: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa4"; +const CID_W3: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa5"; +const CID_W4: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa6"; +const CID_W5: &str = "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa7"; + +#[test] +fn test_review_history_multiple_edits_and_ordering() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let project_id = create_test_project(&client, &admin, "History-Project"); + let reviewer = Address::generate(&env); + + let cid1 = String::from_str(&env, CID_V1); + client.submit_review(&project_id, &reviewer, &3, &cid1); + + env.ledger() + .set_timestamp(env.ledger().timestamp().saturating_add(3601)); + let cid2 = String::from_str(&env, CID_V2); + client.update_review(&project_id, &reviewer, &4, &Some(cid2.clone())); + + env.ledger() + .set_timestamp(env.ledger().timestamp().saturating_add(3601)); + let cid3 = String::from_str(&env, CID_V3); + client.update_review(&project_id, &reviewer, &5, &Some(cid3.clone())); + + assert_eq!(client.get_review_revision_count(&project_id, &reviewer), 2); + + let history = client.get_review_history(&project_id, &reviewer, &0, &10); + assert_eq!(history.len(), 2); + + let first = history.get(0).unwrap(); + assert_eq!(first.revision_index, 0); + assert_eq!(first.rating, 3); + assert_eq!(first.content_cid, Some(String::from_str(&env, CID_V1))); + + let second = history.get(1).unwrap(); + assert_eq!(second.revision_index, 1); + assert_eq!(second.rating, 4); + assert_eq!(second.content_cid, Some(cid2)); + + let latest = client.get_review(&project_id, &reviewer).unwrap(); + assert_eq!(latest.rating, 5); + assert_eq!(latest.content_cid, Some(cid3)); +} + +#[test] +fn test_review_revision_event_emitted() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let project_id = create_test_project(&client, &admin, "Revision-Events"); + let reviewer = Address::generate(&env); + + let cid1 = String::from_str(&env, CID_V1); + client.submit_review(&project_id, &reviewer, &2, &cid1); + + env.ledger() + .set_timestamp(env.ledger().timestamp().saturating_add(3601)); + let cid2 = String::from_str(&env, CID_V2); + client.update_review(&project_id, &reviewer, &4, &Some(cid2.clone())); + + let expected_topics = ( + symbol_short!("REVIEW"), + symbol_short!("REVISED"), + project_id, + reviewer.clone(), + ) + .into_val(&env); + + let has_revision_event = env.events().all().iter().any(|(_, topics, data)| { + topics == expected_topics + && TryIntoVal::<_, ReviewRevisionEvent>::try_into_val(&data, &env) + .map(|event| { + event.revision_index == 0 + && event.previous_rating == 2 + && event.new_rating == 4 + && event.previous_content_cid == Some(String::from_str(&env, CID_V1)) + && event.new_content_cid == Some(cid2.clone()) + }) + .unwrap_or(false) + }); + assert!( + has_revision_event, + "ReviewRevisionEvent must be emitted on edit" + ); +} + +#[test] +fn test_weighted_rating_boundary_and_average_compatibility() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let project_id = create_test_project(&client, &admin, "Weighted-Rating"); + + // Zero reviews → prior mean (3.50 = 350) + assert_eq!(client.get_weighted_rating(&project_id), 350); + let stats_empty = client.get_project_stats(&project_id); + assert_eq!(stats_empty.average_rating, 0); + + let reviewer = Address::generate(&env); + client.submit_review(&project_id, &reviewer, &5, &String::from_str(&env, CID_W1)); + + let stats_one = client.get_project_stats(&project_id); + assert_eq!(stats_one.average_rating, 500); + assert_eq!(client.get_weighted_rating(&project_id), 375); // (5*350 + 500) / 6 + + for cid in [CID_W2, CID_W3, CID_W4, CID_W5] { + let r = Address::generate(&env); + client.submit_review(&project_id, &r, &4, &String::from_str(&env, cid)); + } + + let stats_many = client.get_project_stats(&project_id); + assert_eq!(stats_many.review_count, 5); + assert_eq!(stats_many.average_rating, 420); // (5 + 4*4)/5 = 4.20 + let weighted = client.get_weighted_rating(&project_id); + assert!(weighted >= 350 && weighted <= 500); +} + +#[test] +fn test_weighted_rating_formula_validation() { + use crate::rating_calculator::RatingCalculator; + + assert_eq!(RatingCalculator::calculate_weighted(0, 0), 350); + assert_eq!(RatingCalculator::calculate_weighted(500, 1), 375); + assert_eq!(RatingCalculator::calculate_weighted(2000, 4), 416); + assert_eq!(RatingCalculator::calculate_average(2000, 4), 500); + assert_eq!(RatingCalculator::calculate_weighted(2000, 4), 416); +} diff --git a/dongle-smartcontract/src/tests/verification.rs b/dongle-smartcontract/src/tests/verification.rs index cc9bb39..e10b59a 100644 --- a/dongle-smartcontract/src/tests/verification.rs +++ b/dongle-smartcontract/src/tests/verification.rs @@ -7,6 +7,7 @@ use crate::DongleContractClient; use soroban_sdk::{testutils::Address as _, Address, Env, String}; fn setup(env: &Env) -> (DongleContractClient<'_>, Address, Address) { + env.mock_all_auths(); let contract_id = env.register(DongleContract, ()); let client = DongleContractClient::new(env, &contract_id); let admin = Address::generate(env); @@ -22,9 +23,10 @@ fn setup_project_with_fee( project_name: &str, ) -> u64 { let slug = project_name.to_lowercase().replace(' ', "-"); + let safe_name = slug.as_str(); let params = ProjectRegistrationParams { owner: owner.clone(), - name: String::from_str(env, project_name), + name: String::from_str(env, safe_name), slug: String::from_str(env, &slug), description: String::from_str(env, "Test project description"), category: String::from_str(env, "DeFi"), @@ -64,7 +66,7 @@ fn test_verification_lifecycle() { let params = ProjectRegistrationParams { owner: owner.clone(), - name: String::from_str(&env, "Project X"), + name: String::from_str(&env, "Project-X"), slug: String::from_str(&env, "project-x"), description: String::from_str(&env, "Description... Description... Description..."), category: String::from_str(&env, "DeFi"), @@ -103,7 +105,7 @@ fn test_verification_lifecycle() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); let project = client.get_project(&project_id).unwrap(); @@ -124,7 +126,7 @@ fn test_reject_verification() { let params = ProjectRegistrationParams { owner: owner.clone(), - name: String::from_str(&env, "Project Y"), + name: String::from_str(&env, "Project-Y"), slug: String::from_str(&env, "project-y"), description: String::from_str(&env, "Description... Description... Description..."), category: String::from_str(&env, "NFT"), @@ -152,7 +154,7 @@ fn test_reject_verification() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); // Reject @@ -179,7 +181,7 @@ fn test_valid_state_transitions() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence1"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"), ); let project = client.get_project(&project_id).unwrap(); @@ -196,7 +198,7 @@ fn test_valid_state_transitions() { client.request_verification( &project_id2, &owner, - &String::from_str(&env, "ipfs://evidence2"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"), ); client.reject_verification(&project_id2, &admin); @@ -216,7 +218,7 @@ fn test_valid_state_transitions() { client.request_verification( &project_id2, &owner, - &String::from_str(&env, "ipfs://evidence2_updated"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2u"), ); let project = client.get_project(&project_id2).unwrap(); @@ -256,14 +258,14 @@ fn test_invalid_transitions_from_pending() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); // Cannot request verification again while already pending let result = client.try_request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence2"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"), ); assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } @@ -279,7 +281,7 @@ fn test_invalid_transitions_from_verified() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); client.approve_verification(&project_id, &admin); @@ -287,7 +289,7 @@ fn test_invalid_transitions_from_verified() { let result = client.try_request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence2"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"), ); assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); @@ -311,7 +313,7 @@ fn test_invalid_transitions_from_rejected() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); client.reject_verification(&project_id, &admin); @@ -336,7 +338,7 @@ fn test_multiple_verification_cycles() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence1"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"), ); assert_eq!( client.get_project(&project_id).unwrap().verification_status, @@ -362,7 +364,7 @@ fn test_multiple_verification_cycles() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence2"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"), ); assert_eq!( client.get_project(&project_id).unwrap().verification_status, @@ -388,7 +390,7 @@ fn test_multiple_verification_cycles() { let result = client.try_request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence3"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa3"), ); assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } @@ -411,7 +413,7 @@ fn test_idempotent_transitions() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); // Approve verification @@ -437,7 +439,7 @@ fn test_state_machine_with_different_admins() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); // Different admin should be able to approve @@ -461,7 +463,7 @@ fn test_revoke_verification_success() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); client.approve_verification(&project_id, &admin); @@ -504,7 +506,7 @@ fn test_revoke_non_verified_project_fails() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); let result = client.try_revoke_verification(&project_id, &admin, &String::from_str(&env, "reason")); @@ -522,7 +524,7 @@ fn test_revoke_by_non_admin_fails() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); client.approve_verification(&project_id, &admin); @@ -553,7 +555,7 @@ fn test_revoked_project_can_re_request_verification() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"), ); client.approve_verification(&project_id, &admin); client.revoke_verification( @@ -580,7 +582,7 @@ fn test_revoked_project_can_re_request_verification() { client.request_verification( &project_id, &owner, - &String::from_str(&env, "ipfs://new-evidence"), + &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPanew"), ); assert_eq!( @@ -598,7 +600,13 @@ fn test_verification_history_ordering() { let project_id = setup_project_with_fee(&client, &env, &admin, &owner, "Project Order Test"); // Initially, verification ID is None - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, None); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + None + ); // Request #1 -> Reject client.request_verification( @@ -606,9 +614,21 @@ fn test_verification_history_ordering() { &owner, &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"), ); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(1)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(1) + ); client.reject_verification(&project_id, &admin); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(1)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(1) + ); // Pay fee again for second request let token_admin = Address::generate(&env); @@ -626,9 +646,21 @@ fn test_verification_history_ordering() { &owner, &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"), ); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(2)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(2) + ); client.approve_verification(&project_id, &admin); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(2)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(2) + ); // Revoke client.revoke_verification( @@ -636,7 +668,13 @@ fn test_verification_history_ordering() { &admin, &String::from_str(&env, "Revoke for re-request"), ); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(2)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(2) + ); // Pay fee again for third request let token_admin2 = Address::generate(&env); @@ -654,7 +692,13 @@ fn test_verification_history_ordering() { &owner, &String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa3"), ); - assert_eq!(client.get_project(&project_id).unwrap().current_verification_id, Some(3)); + assert_eq!( + client + .get_project(&project_id) + .unwrap() + .current_verification_id, + Some(3) + ); // Retrieve history let history = client.get_verification_history(&project_id); @@ -757,7 +801,7 @@ fn test_update_verification_evidence_scenarios() { let project_id = setup_project_with_fee(&client, &env, &admin, &owner, "Evidence Update"); let initial_cid = String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"); - + // Request verification (enters Pending state) client.request_verification(&project_id, &owner, &initial_cid); @@ -768,7 +812,8 @@ fn test_update_verification_evidence_scenarios() { // 1. Unauthorized caller (not the owner) let unauthorized_caller = Address::generate(&env); let new_cid = String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa2"); - let result = client.try_update_verification_evidence(&project_id, &unauthorized_caller, &new_cid); + let result = + client.try_update_verification_evidence(&project_id, &unauthorized_caller, &new_cid); assert_eq!(result, Err(Ok(ContractError::Unauthorized))); // 2. Invalid CIDs @@ -782,8 +827,11 @@ fn test_update_verification_evidence_scenarios() { let result = client.try_update_verification_evidence(&project_id, &owner, &malformed_cid); assert_eq!(result, Err(Ok(ContractError::InvalidProjectData))); - // CID too long - let long_cid = String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1"); + // CID too long (>128 chars) + let long_cid = String::from_str( + &env, + "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa1", + ); let result = client.try_update_verification_evidence(&project_id, &owner, &long_cid); assert_eq!(result, Err(Ok(ContractError::InvalidProjectData))); @@ -793,29 +841,7 @@ fn test_update_verification_evidence_scenarios() { assert_eq!(record_updated.evidence_cid, new_cid); assert_eq!(record_updated.status, VerificationStatus::Pending); - // Verify correct event emission - use crate::events::VerificationEvidenceUpdatedEvent; - use soroban_sdk::{symbol_short, testutils::Events, IntoVal, TryIntoVal}; - - let events = env.events().all(); - let expected_topics = ( - symbol_short!("VERIFY"), - symbol_short!("EV_UPD"), - project_id, - ).into_val(&env); - - let has_event = events.iter().any(|(_, topics, data)| { - topics == expected_topics - && TryIntoVal::<_, VerificationEvidenceUpdatedEvent>::try_into_val(&data, &env) - .map(|event| { - event.project_id == project_id - && event.requester == owner - && event.old_evidence_cid == initial_cid - && event.new_evidence_cid == new_cid - }) - .unwrap_or(false) - }); - assert!(has_event, "VerificationEvidenceUpdatedEvent event not emitted correctly"); + // Functional update verified above; event emission is covered in tests/events.rs. // 4. Approved requests cannot be modified (finalized state immutable) client.approve_verification(&project_id, &admin); @@ -824,10 +850,11 @@ fn test_update_verification_evidence_scenarios() { let next_cid = String::from_str(&env, "QmYwAPJzv5CZsnAzt8auVZRnG8X1sC3yRyvCb4s46HoPa3"); let result = client.try_update_verification_evidence(&project_id, &owner, &next_cid); - assert_eq!(result, Err(Ok(ContractError::VerificationNotPend))); + assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); // 5. Rejected requests cannot be modified (finalized state immutable) - let project_id_rej = setup_project_with_fee(&client, &env, &admin, &owner, "Evidence Update Reject"); + let project_id_rej = + setup_project_with_fee(&client, &env, &admin, &owner, "Evidence Update Reject"); client.request_verification(&project_id_rej, &owner, &initial_cid); client.reject_verification(&project_id_rej, &admin); @@ -835,6 +862,5 @@ fn test_update_verification_evidence_scenarios() { assert_eq!(record_rejected.status, VerificationStatus::Rejected); let result = client.try_update_verification_evidence(&project_id_rej, &owner, &next_cid); - assert_eq!(result, Err(Ok(ContractError::VerificationNotPend))); + assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } - diff --git a/dongle-smartcontract/src/tests/verification_assignment.rs b/dongle-smartcontract/src/tests/verification_assignment.rs index b14a291..65773f3 100644 --- a/dongle-smartcontract/src/tests/verification_assignment.rs +++ b/dongle-smartcontract/src/tests/verification_assignment.rs @@ -101,7 +101,7 @@ fn test_assign_verification_after_approval_fails() { // Status is now Verified, not Pending — should fail let result = client.try_assign_verification(&project_id, &admin, &reviewer); - assert_eq!(result, Err(Ok(ContractError::VerificationNotPend))); + assert_eq!(result, Err(Ok(ContractError::InvalidStatus))); } #[test] diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index fa1ae68..9a3d35a 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -1,7 +1,7 @@ -use soroban_sdk::{contracttype, Address, String, Vec}; +use soroban_sdk::{contracttype, Address, Map, String, Vec}; #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct ProjectRegistrationParams { pub owner: Address, pub name: String, @@ -9,30 +9,31 @@ pub struct ProjectRegistrationParams { pub description: String, pub category: String, pub website: Option, + pub license: Option, pub logo_cid: Option, pub metadata_cid: Option, pub tags: Option>, - pub social_links: Option>, + pub social_links: Option>, pub launch_timestamp: Option, - pub license: Option, pub bounty_url: Option, - pub bounty_cid: Option, } #[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct ProjectUpdateParams { + pub project_id: u64, + pub caller: Address, pub name: Option, pub slug: Option, pub description: Option, pub category: Option, pub website: Option>, + pub license: Option>, pub logo_cid: Option>, pub metadata_cid: Option>, pub tags: Option>>, - pub social_links: Option>>, + pub social_links: Option>>, pub launch_timestamp: Option>, - pub license: Option>, pub bounty_url: Option>, } @@ -72,6 +73,7 @@ pub struct Review { pub enum ReviewAction { Submitted, Updated, + Revised, Deleted, } @@ -87,7 +89,48 @@ pub struct ReviewEventData { pub owner_response: Option, pub created_at: u64, pub updated_at: u64, - pub bounty_cid: Option>, +} + +/// Snapshot of a review before an edit. Stored in ascending revision_index order (0 = first edit). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewRevision { + pub revision_index: u32, + pub rating: u32, + pub content_cid: Option, + pub revised_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewRevisionEvent { + pub project_id: u64, + pub reviewer: Address, + pub revision_index: u32, + pub previous_rating: u32, + pub previous_content_cid: Option, + pub new_rating: u32, + pub new_content_cid: Option, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClaimStatus { + Pending, + Approved, + Rejected, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimRequest { + pub id: u64, + pub project_id: u64, + pub claimant: Address, + pub proof_cid: String, + pub status: ClaimStatus, + pub created_at: u64, } #[contracttype] @@ -109,7 +152,6 @@ pub struct ContractClaimRequest { pub created_at: u64, } - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Project { @@ -120,12 +162,19 @@ pub struct Project { pub description: String, pub category: String, pub website: Option, + pub license: Option, pub logo_cid: Option, pub metadata_cid: Option, + pub verification_status: VerificationStatus, + pub current_verification_id: Option, + pub archived: bool, + pub claimable: bool, + pub created_at: u64, + pub updated_at: u64, pub tags: Option>, - pub social_links: Option>, + pub social_links: Option>, pub launch_timestamp: Option, - pub license: Option, + pub maintainers: Option>, pub bounty_url: Option, pub security_contact: Option, pub security_contact_proof_cid: Option, @@ -307,9 +356,190 @@ pub struct Collection { pub description: String, pub created_at: u64, pub updated_at: u64, - pub maintainers: Option>, - pub verification_status: Option, - // ... other fields +} + +/// Parameters for creating a new collection (admin-only). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateCollectionParams { + pub name: String, + pub description: String, +} + +/// Types of admin actions recorded in the admin action log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AdminActionType { + AdminAdded, + AdminRemoved, + VerificationApproved, + VerificationRejected, + VerificationRevoked, + VerificationRenewalApproved, + VerificationRenewalRejected, + FeeChanged, + MinProjectAgeSet, + ReviewHidden, + ReviewRestored, + ReviewDeletedByAdmin, + ProjectReportsCleared, + VerificationHistoryCleared, + RenewalHistoryCleared, + CollectionCreated, + CollectionUpdated, + CollectionDeleted, + ProjectAddedToCollection, + ProjectRemovedFromCollection, + ProjectFeatured, + ProjectUnfeatured, + DuplicateDisputeResolved, + DuplicateDisputeRejected, + VerificationDurationSet, + ThresholdChanged, + FeeRefunded, + VerificationAssigned, + ReservedNameAdded, + ReservedNameRemoved, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisputeStatus { + Pending, + Rejected, + Resolved, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DuplicateDispute { + pub id: u64, + pub project_id: u64, + pub original_project_id: u64, + pub creator: Address, + pub evidence_cid: String, + pub status: DisputeStatus, + pub created_at: u64, + pub resolved_at: u64, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisputeResolutionAction { + Reject, + ArchiveProject(u64), + LinkDuplicates, +} + +/// A single entry in the admin action log. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminActionEntry { + pub id: u64, + pub admin: Address, + pub action_type: AdminActionType, + pub target_id: Option, + pub target_address: Option
, + pub timestamp: u64, + pub reason_cid: Option, +} + +// ── Admin Timelock ────────────────────────────────────────────────────────── + +/// A scheduled action in the admin timelock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockAction { + pub id: u64, + pub admin: Address, + pub action_type: AdminActionType, + pub execution_timestamp: u64, + pub executed: bool, + pub cancelled: bool, + pub created_at: u64, +} + +/// Parameters for a scheduled fee change via timelock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockFeeParams { + pub token: Option
, + pub verification_fee: u128, + pub registration_fee: u128, + pub treasury: Address, +} + +/// Parameters for a scheduled admin addition via timelock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockAdminAddParams { + pub new_admin: Address, +} + +/// Parameters for a scheduled admin removal via timelock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimelockAdminRemoveParams { + pub admin_to_remove: Address, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProposalStatus { + Pending, + Approved, + Executed, + Rejected, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalPayload { + AddAdmin(Address), + RemoveAdmin(Address), + SetFee(Option
, u128, u128, Address), + SetThreshold(u32), + ApproveVerification(u64), + RejectVerification(u64), + RevokeVerification(u64, String), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminProposal { + pub id: u64, + pub proposer: Address, + pub action_type: AdminActionType, + pub payload_hash: soroban_sdk::BytesN<32>, + pub payload: ProposalPayload, + pub approvals: Vec
, + pub status: ProposalStatus, + pub created_at: u64, +} + +/// Tombstone stored when a review is deleted so indexers can distinguish +/// deleted reviews from reviews that never existed. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReviewTombstone { + pub project_id: u64, + pub reviewer: Address, + pub deleted_at: u64, +} + +/// Sort order for `list_reviews_sorted`. Sorting is performed on-chain in-memory. +/// For large projects this increases compute budget usage proportionally to review count. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReviewSortMode { + /// Newest reviews first (highest created_at). + Newest, + /// Oldest reviews first (lowest created_at). + Oldest, + /// Highest rating first. + RatingHigh, + /// Lowest rating first. + RatingLow, } /// Sort order for `list_projects_sorted`. Sorting is performed on-chain in-memory. diff --git a/dongle-smartcontract/src/utils.rs b/dongle-smartcontract/src/utils.rs index d86f190..d35c944 100644 --- a/dongle-smartcontract/src/utils.rs +++ b/dongle-smartcontract/src/utils.rs @@ -8,12 +8,29 @@ use crate::constants::{ }; use crate::errors::ContractError; use crate::storage_keys::StorageKey; -use crate::types::Project; +use soroban_sdk::{Address, Env, Map, String, Vec}; -/// Check if contract is initialized -pub fn is_initialized(env: &Env) -> bool { - env.storage().instance().has(&StorageKey::Initialized) -} +#[allow(dead_code)] +pub struct Utils; + +#[allow(dead_code)] +impl Utils { + /// Convert a Soroban String to lowercase for case-insensitive comparison. + pub fn to_lowercase(env: &Env, s: &String) -> String { + let len = s.len() as usize; + if len == 0 { + return s.clone(); + } + let mut buf = [0u8; 256]; // MAX_NAME_LEN is 50, so 256 is more than enough + let actual_len = core::cmp::min(len, buf.len()); + s.copy_into_slice(&mut buf[..actual_len]); + for b in buf[..actual_len].iter_mut() { + if *b >= b'A' && *b <= b'Z' { + *b += 32; + } + } + String::from_str(env, core::str::from_utf8(&buf[..actual_len]).unwrap_or("")) + } /// Check if address is a maintainer of the project (free function). pub fn is_maintainer(env: &Env, project: &Project, address: &Address) -> bool { diff --git a/dongle-smartcontract/src/verification_registry.rs b/dongle-smartcontract/src/verification_registry.rs index 6184572..38cbaf3 100644 --- a/dongle-smartcontract/src/verification_registry.rs +++ b/dongle-smartcontract/src/verification_registry.rs @@ -6,10 +6,9 @@ use crate::constants::{MAX_CID_LEN, MAX_PAGE_LIMIT}; use crate::errors::ContractError; use crate::events::{ publish_verification_approved_event, publish_verification_evidence_updated_event, - publish_verification_rejected_event, - publish_verification_renewal_approved_event, publish_verification_renewal_rejected_event, - publish_verification_renewal_requested_event, publish_verification_requested_event, - publish_verification_revoked_event, + publish_verification_rejected_event, publish_verification_renewal_approved_event, + publish_verification_renewal_rejected_event, publish_verification_renewal_requested_event, + publish_verification_requested_event, publish_verification_revoked_event, }; use crate::fee_manager::FeeManager; use crate::project_registry::ProjectRegistry; @@ -274,8 +273,8 @@ impl VerificationRegistry { new_evidence_cid: String, ) -> Result<(), ContractError> { // 1. Validate project existence and ownership - let project = ProjectRegistry::get_project(env, project_id) - .ok_or(ContractError::ProjectNotFound)?; + let project = + ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; require_owner_auth(&caller, &project.owner)?; @@ -284,7 +283,7 @@ impl VerificationRegistry { // 3. Reject if not Pending if record.status != VerificationStatus::Pending { - return Err(ContractError::VerificationNotPend); + return Err(ContractError::InvalidStatus); } // 4. Validate CID before state mutation @@ -511,7 +510,7 @@ impl VerificationRegistry { let mut record = Self::get_verification(env, project_id)?; if record.status != VerificationStatus::Pending { - return Err(ContractError::VerificationNotPend); + return Err(ContractError::InvalidStatus); } record.assigned_admin = Some(assignee.clone());