From 3acba9bc7136a69f11a0e805fe4989d94267d428 Mon Sep 17 00:00:00 2001 From: Tosin Adegoke Date: Sat, 27 Jun 2026 19:44:19 +0100 Subject: [PATCH] Reset verification on major metadata changes --- dongle-smartcontract/src/admin_manager.rs | 14 ++- dongle-smartcontract/src/constants.rs | 10 ++ dongle-smartcontract/src/events.rs | 48 +++++++++- dongle-smartcontract/src/fee_manager.rs | 10 +- dongle-smartcontract/src/project_registry.rs | 61 ++++++++++++- dongle-smartcontract/src/rating_calculator.rs | 1 + dongle-smartcontract/src/review_registry.rs | 5 +- .../src/tests/dependencies.rs | 10 +- .../src/tests/fee_token_rotation.rs | 6 +- .../src/tests/field_limits.rs | 1 - dongle-smartcontract/src/tests/fixtures.rs | 1 - .../src/tests/index_limits.rs | 40 ++++---- .../src/tests/string_validation.rs | 24 ++++- .../src/tests/verified_freeze.rs | 91 +++++++++++++++---- dongle-smartcontract/src/utils.rs | 21 ++--- 15 files changed, 262 insertions(+), 81 deletions(-) diff --git a/dongle-smartcontract/src/admin_manager.rs b/dongle-smartcontract/src/admin_manager.rs index d37190b9..8ebd30bf 100644 --- a/dongle-smartcontract/src/admin_manager.rs +++ b/dongle-smartcontract/src/admin_manager.rs @@ -448,7 +448,12 @@ impl AdminManager { env.storage() .persistent() .set(&StorageKey::Project(project_id), &project); - crate::events::publish_verification_approved_event(env, project_id, caller.clone(), now); + crate::events::publish_verification_approved_event( + env, + project_id, + caller.clone(), + now, + ); } ProposalPayload::RejectVerification(project_id) => { let mut project = @@ -477,7 +482,12 @@ impl AdminManager { env.storage() .persistent() .set(&StorageKey::Project(project_id), &project); - crate::events::publish_verification_rejected_event(env, project_id, caller.clone(), now); + crate::events::publish_verification_rejected_event( + env, + project_id, + caller.clone(), + now, + ); } ProposalPayload::RevokeVerification(project_id, reason) => { let mut project = diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index 93b91597..89c6fa45 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -45,6 +45,16 @@ pub const MAX_WEBSITE_LEN: usize = 256; #[allow(dead_code)] pub const MAX_CID_LEN: usize = 128; +/// 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"; +pub const MAJOR_METADATA_FIELD_METADATA_CID: &str = "metadata_cid"; +pub const MAJOR_METADATA_FIELDS: [&str; 3] = [ + MAJOR_METADATA_FIELD_NAME, + MAJOR_METADATA_FIELD_WEBSITE, + MAJOR_METADATA_FIELD_METADATA_CID, +]; + /// Minimum project age in seconds before verification can be requested (default: 0 for backward compatibility). pub const MIN_PROJECT_AGE_SECONDS: u64 = 0; diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index 6e0f3d10..3901e205 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -1,4 +1,4 @@ -use crate::types::{AdminActionType, ReviewAction, ReviewEventData}; +use crate::types::{AdminActionType, ReviewAction, ReviewEventData, VerificationStatus}; use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, Vec}; pub const REVIEW: Symbol = symbol_short!("REVIEW"); @@ -30,6 +30,17 @@ pub struct ProjectUpdatedEvent { pub timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerificationStatusResetEvent { + pub project_id: u64, + pub caller: Address, + pub previous_status: VerificationStatus, + pub new_status: VerificationStatus, + pub fields: Vec, + pub timestamp: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProjectArchivedEvent { @@ -347,6 +358,27 @@ pub fn publish_project_updated_event(env: &Env, project_id: u64, owner: Address) ); } +pub fn publish_verification_status_reset_event( + env: &Env, + project_id: u64, + caller: Address, + previous_status: VerificationStatus, + fields: Vec, +) { + let event_data = VerificationStatusResetEvent { + project_id, + caller, + previous_status, + new_status: VerificationStatus::Unverified, + fields, + timestamp: env.ledger().timestamp(), + }; + env.events().publish( + (symbol_short!("VERIFY"), symbol_short!("RESET"), project_id), + event_data, + ); +} + pub fn publish_project_archived_event(env: &Env, project_id: u64, archived_by: Address) { let event_data = ProjectArchivedEvent { project_id, @@ -598,7 +630,12 @@ pub fn publish_verification_requested_event( ); } -pub fn publish_verification_approved_event(env: &Env, project_id: u64, admin: Address, decided_at: u64) { +pub fn publish_verification_approved_event( + env: &Env, + project_id: u64, + admin: Address, + decided_at: u64, +) { let event_data = VerificationApprovedEvent { project_id, admin, @@ -611,7 +648,12 @@ pub fn publish_verification_approved_event(env: &Env, project_id: u64, admin: Ad ); } -pub fn publish_verification_rejected_event(env: &Env, project_id: u64, admin: Address, decided_at: u64) { +pub fn publish_verification_rejected_event( + env: &Env, + project_id: u64, + admin: Address, + decided_at: u64, +) { let event_data = VerificationRejectedEvent { project_id, admin, diff --git a/dongle-smartcontract/src/fee_manager.rs b/dongle-smartcontract/src/fee_manager.rs index a8b41fec..965b6384 100644 --- a/dongle-smartcontract/src/fee_manager.rs +++ b/dongle-smartcontract/src/fee_manager.rs @@ -57,7 +57,7 @@ impl FeeManager { /// Pay the verification fee for a project. /// Only the project owner may pay; third-party payments are rejected. - /// + /// /// # Behavior on Token Transfer Failure /// - If the token transfer fails (e.g., insufficient balance), the payment flag is NOT set /// - The fee paid event is NOT emitted @@ -104,8 +104,6 @@ impl FeeManager { .persistent() .set(&StorageKey::FeePaidForProject(project_id), &true); - // Only emit event after successful payment - publish_fee_paid_event(env, project_id, payer, amount); publish_fee_paid_event( env, project_id, @@ -217,12 +215,6 @@ impl FeeManager { } // Only set payment flag after successful token transfer - env.storage() - .persistent() - .set(&StorageKey::RegistrationFeePaidForAddress(payer.clone()), &true); - - // Only emit event after successful payment - publish_fee_paid_event(env, 0, payer, amount); env.storage().persistent().set( &StorageKey::RegistrationFeePaidForAddress(payer.clone()), &true, diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index b4326508..b768041c 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -1,12 +1,15 @@ use crate::admin_manager::AdminManager; -use crate::constants::{MAX_PAGE_LIMIT, MAX_PROJECTS_PER_USER}; +use crate::constants::{ + MAJOR_METADATA_FIELD_METADATA_CID, MAJOR_METADATA_FIELD_NAME, MAJOR_METADATA_FIELD_WEBSITE, + MAX_PAGE_LIMIT, MAX_PROJECTS_PER_USER, +}; use crate::errors::ContractError; use crate::events::{ publish_claim_request_approved_event, publish_claim_request_rejected_event, publish_claim_request_submitted_event, publish_ownership_transferred_event, publish_project_archived_event, publish_project_claimable_set_event, publish_project_reactivated_event, publish_project_registered_event, - publish_project_updated_event, + publish_project_updated_event, publish_verification_status_reset_event, }; use crate::fee_manager::FeeManager; use crate::storage_keys::{ExtensionKey, StorageKey}; @@ -213,8 +216,7 @@ impl ProjectRegistry { // ── Metadata freeze guard ────────────────────────────────────────── // For verified projects, identity-critical fields are frozen. // Detect whether any frozen field is being changed before mutating. - let is_verified = - project.verification_status == VerificationStatus::Verified; + let is_verified = project.verification_status == VerificationStatus::Verified; let new_name_differs = params .name @@ -241,6 +243,11 @@ impl ProjectRegistry { .as_ref() .map(|opt| opt.as_ref() != project.metadata_cid.as_ref()) .unwrap_or(false); + let new_website_differs = params + .website + .as_ref() + .map(|opt| opt.as_ref() != project.website.as_ref()) + .unwrap_or(false); Utils::check_frozen_fields( is_verified, @@ -250,6 +257,21 @@ impl ProjectRegistry { new_logo_differs, new_meta_differs, )?; + + let major_metadata_changed = + is_verified && (new_name_differs || new_website_differs || new_meta_differs); + let mut major_fields: Vec = Vec::new(env); + if major_metadata_changed { + if new_name_differs { + major_fields.push_back(String::from_str(env, MAJOR_METADATA_FIELD_NAME)); + } + if new_website_differs { + major_fields.push_back(String::from_str(env, MAJOR_METADATA_FIELD_WEBSITE)); + } + if new_meta_differs { + major_fields.push_back(String::from_str(env, MAJOR_METADATA_FIELD_METADATA_CID)); + } + } // ───────────────────────────────────────────────────────────────── // Store old name for cleanup if name is being updated @@ -339,6 +361,27 @@ impl ProjectRegistry { project.metadata_cid = value; } + if major_metadata_changed { + let now = env.ledger().timestamp(); + if let Some(request_id) = project.current_verification_id { + if let Some(mut record) = env + .storage() + .persistent() + .get::( + &StorageKey::VerificationRecord(request_id), + ) + { + record.status = VerificationStatus::Unverified; + record.revoke_reason = Some(String::from_str(env, "MajorMetadataChanged")); + record.decided_at = now; + env.storage() + .persistent() + .set(&StorageKey::VerificationRecord(request_id), &record); + } + } + project.verification_status = VerificationStatus::Unverified; + } + // Handle tags update if let Some(value) = params.tags { if let Some(tags) = &value { @@ -480,6 +523,15 @@ impl ProjectRegistry { } publish_project_updated_event(env, params.project_id, project.owner.clone()); + if major_metadata_changed { + publish_verification_status_reset_event( + env, + params.project_id, + params.caller, + VerificationStatus::Verified, + major_fields, + ); + } StorageManager::extend_project_bounty_url_ttl(env, params.project_id); Ok(project) @@ -501,6 +553,7 @@ impl ProjectRegistry { .storage() .persistent() .get(&StorageKey::ProjectSocialLinks(project_id)); + proj.maintainers = Some(Self::get_maintainers(env, project_id)); // proj.bounty_url - bounty_url storage removed from StorageKey } diff --git a/dongle-smartcontract/src/rating_calculator.rs b/dongle-smartcontract/src/rating_calculator.rs index c841940d..bd70ad38 100644 --- a/dongle-smartcontract/src/rating_calculator.rs +++ b/dongle-smartcontract/src/rating_calculator.rs @@ -120,6 +120,7 @@ mod prop_tests { count in 1u32..MAX_COUNT, rating in RATING_RANGE, ) { + prop_assume!(sum >= (rating as u64) * 100); let (new_sum, new_count, new_avg) = RatingCalculator::update_rating(sum, count, rating, rating); prop_assert_eq!(new_sum, sum); diff --git a/dongle-smartcontract/src/review_registry.rs b/dongle-smartcontract/src/review_registry.rs index e395c6ed..1ec65430 100644 --- a/dongle-smartcontract/src/review_registry.rs +++ b/dongle-smartcontract/src/review_registry.rs @@ -1,7 +1,10 @@ //! Review registry: create/update/delete reviews and maintain aggregates and indexes. 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}; +use crate::constants::{ + MAX_CID_LEN, MAX_PAGE_LIMIT, MAX_REVIEWS_PER_PROJECT, MAX_REVIEWS_PER_USER, RATING_MAX, + RATING_MIN, +}; use crate::errors::ContractError; use crate::events::publish_review_event; use crate::project_registry::ProjectRegistry; diff --git a/dongle-smartcontract/src/tests/dependencies.rs b/dongle-smartcontract/src/tests/dependencies.rs index c7b4686f..759d0339 100644 --- a/dongle-smartcontract/src/tests/dependencies.rs +++ b/dongle-smartcontract/src/tests/dependencies.rs @@ -179,11 +179,17 @@ fn test_invalid_dependency_reference_rejected() { fn mk_contract_address(env: &Env) -> String { // Valid 56-char Stellar contract address (starts with 'C', uppercase base32) - String::from_str(env, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + String::from_str( + env, + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ) } fn mk_contract_address_2(env: &Env) -> String { - String::from_str(env, "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB5") + String::from_str( + env, + "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB5", + ) } #[test] diff --git a/dongle-smartcontract/src/tests/fee_token_rotation.rs b/dongle-smartcontract/src/tests/fee_token_rotation.rs index 0e9b7ed0..689acd22 100644 --- a/dongle-smartcontract/src/tests/fee_token_rotation.rs +++ b/dongle-smartcontract/src/tests/fee_token_rotation.rs @@ -225,7 +225,10 @@ fn test_pay_fee_with_none_token_sets_flag_at_zero_fee() { // pay_fee with None token at zero fee must succeed without any transfer let result = client.try_pay_fee(&owner, &project_id, &None); - assert!(result.is_ok(), "zero-fee payment with None token should succeed"); + assert!( + result.is_ok(), + "zero-fee payment with None token should succeed" + ); } #[test] @@ -390,4 +393,3 @@ fn test_non_admin_cannot_rotate_fee_token() { "non-admin must not be allowed to rotate the fee token" ); } - diff --git a/dongle-smartcontract/src/tests/field_limits.rs b/dongle-smartcontract/src/tests/field_limits.rs index cbaa33e9..518f7c4c 100644 --- a/dongle-smartcontract/src/tests/field_limits.rs +++ b/dongle-smartcontract/src/tests/field_limits.rs @@ -526,4 +526,3 @@ fn boundary_cid_at_127_passes() { assert!(client.try_register_project(&p).is_ok()); let _ = admin; } - diff --git a/dongle-smartcontract/src/tests/fixtures.rs b/dongle-smartcontract/src/tests/fixtures.rs index ae75a88c..6fa9fbae 100644 --- a/dongle-smartcontract/src/tests/fixtures.rs +++ b/dongle-smartcontract/src/tests/fixtures.rs @@ -99,4 +99,3 @@ pub fn assert_project_state( assert_eq!(project.owner, *expected_owner); assert_eq!(project.verification_status, expected_status); } - diff --git a/dongle-smartcontract/src/tests/index_limits.rs b/dongle-smartcontract/src/tests/index_limits.rs index 3ed45cda..31cdf994 100644 --- a/dongle-smartcontract/src/tests/index_limits.rs +++ b/dongle-smartcontract/src/tests/index_limits.rs @@ -1,8 +1,6 @@ //! Storage index size limit tests: owner projects and review indexes. -use crate::constants::{ - MAX_PROJECTS_PER_USER, MAX_REVIEWS_PER_PROJECT, MAX_REVIEWS_PER_USER, -}; +use crate::constants::{MAX_PROJECTS_PER_USER, MAX_REVIEWS_PER_PROJECT, MAX_REVIEWS_PER_USER}; use crate::errors::ContractError; use crate::tests::fixtures::{create_test_project, setup_contract}; use crate::types::ProjectRegistrationParams; @@ -48,22 +46,27 @@ fn test_max_projects_per_user_enforced() { register_project_for_owner(&env, &client, &owner, &name); } - assert_eq!(client.get_owner_project_count(&owner), MAX_PROJECTS_PER_USER); + 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, - }); + 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()))); } @@ -179,4 +182,3 @@ fn test_max_reviews_per_user_enforced() { assert_eq!(result, Err(Ok(ContractError::MaxProjectsExceeded.into()))); } - diff --git a/dongle-smartcontract/src/tests/string_validation.rs b/dongle-smartcontract/src/tests/string_validation.rs index 28b8f813..e90ed2fa 100644 --- a/dongle-smartcontract/src/tests/string_validation.rs +++ b/dongle-smartcontract/src/tests/string_validation.rs @@ -89,7 +89,15 @@ fn name_whitespace_only_invalid() { #[test] fn name_allowed_chars_valid() { let e = mk_env(); - for name in ["hello", "Hello-World", "my_project", "abc123", "A1-B2_C3", "X", "a-b"] { + for name in [ + "hello", + "Hello-World", + "my_project", + "abc123", + "A1-B2_C3", + "X", + "a-b", + ] { assert!( Utils::validate_project_name(&s(&e, name)).is_ok(), "name {name:?} should be valid" @@ -131,7 +139,10 @@ fn name_disallowed_chars_invalid() { ]; for name in cases { let r = Utils::validate_project_name(&s(&e, name)); - assert!(r.is_err(), "name {name:?} with disallowed char should be invalid"); + assert!( + r.is_err(), + "name {name:?} with disallowed char should be invalid" + ); } } @@ -358,7 +369,9 @@ fn cid_wrong_prefix_invalid() { v.push_str(&repeat_char('m', 45)); v }; - assert!(!Utils::is_valid_ipfs_cid(&SorobanString::from_str(&e, &bad))); + assert!(!Utils::is_valid_ipfs_cid(&SorobanString::from_str( + &e, &bad + ))); } #[test] @@ -540,7 +553,10 @@ fn name_rejects_control_chars() { for ch in ['\x01', '\x07', '\x1b', '\x7f'] { let name = alloc::format!("abc{ch}def"); let result = Utils::validate_project_name(&s(&e, &name)); - assert!(result.is_err(), "name with control char {ch:?} should be rejected"); + assert!( + result.is_err(), + "name with control char {ch:?} should be rejected" + ); } } diff --git a/dongle-smartcontract/src/tests/verified_freeze.rs b/dongle-smartcontract/src/tests/verified_freeze.rs index 42b405fe..b0ef6bd2 100644 --- a/dongle-smartcontract/src/tests/verified_freeze.rs +++ b/dongle-smartcontract/src/tests/verified_freeze.rs @@ -1,6 +1,6 @@ -//! Tests for the metadata freeze policy on verified projects. +//! Tests for verified-project metadata invalidation. //! -//! ## Freeze Policy Summary +//! ## Metadata Policy Summary //! //! Once a project is `Verified`, the following identity-critical fields //! are **frozen** — any attempt to change them is rejected with @@ -14,16 +14,23 @@ //! | `logo_cid` | Visual identity audited during verification | //! | `metadata_cid`| Evidence CID used during the verification review | //! -//! Fields that remain **freely mutable** after verification: -//! `description`, `website`, `tags`, `social_links`, `launch_timestamp`. +//! Major fields (`name`, `website`, `metadata_cid`) reset verification. +//! Minor fields (`description`, `tags`, `social_links`, `launch_timestamp`) +//! remain freely mutable after verification. //! //! To change a frozen field, an admin must first revoke verification; //! the project then returns to `Unverified` status and may be re-verified. +use crate::constants::MAJOR_METADATA_FIELDS; use crate::errors::ContractError; +use crate::events::VerificationStatusResetEvent; use crate::tests::fixtures::{create_test_project, setup_contract}; use crate::types::{ProjectUpdateParams, VerificationStatus}; -use soroban_sdk::{testutils::Address as _, Address, Env, String as SorobanString}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events}, + Address, Env, IntoVal, String as SorobanString, TryIntoVal, Val, +}; // ─── helpers ───────────────────────────────────────────────────────────────── @@ -44,6 +51,28 @@ fn approve_verification( client.approve_verification(&project_id, admin); } +fn decode_event>(env: &Env, data: &Val) -> Option { + TryIntoVal::<_, T>::try_into_val(data, env).ok() +} + +fn has_verification_reset_event(env: &Env, project_id: u64, field: &str) -> bool { + let expected_topics = + (symbol_short!("VERIFY"), symbol_short!("RESET"), project_id).into_val(env); + let expected_field = SorobanString::from_str(env, field); + + env.events().all().iter().any(|(_, topics, data)| { + topics == expected_topics + && decode_event::(env, &data) + .map(|event| { + event.project_id == project_id + && event.previous_status == VerificationStatus::Verified + && event.new_status == VerificationStatus::Unverified + && event.fields.contains(&expected_field) + }) + .unwrap_or(false) + }) +} + // ═══════════════════════════════════════════════════════════════════════════ // Unit-level: Utils::check_frozen_fields // ═══════════════════════════════════════════════════════════════════════════ @@ -58,7 +87,7 @@ fn unit_freeze_unverified_no_restriction() { #[test] fn unit_freeze_verified_name_blocked() { let r = crate::utils::Utils::check_frozen_fields(true, true, false, false, false, false); - assert_eq!(r, Err(ContractError::VerifiedFieldFrozen)); + assert!(r.is_ok()); } #[test] @@ -82,7 +111,7 @@ fn unit_freeze_verified_logo_cid_blocked() { #[test] fn unit_freeze_verified_metadata_cid_blocked() { let r = crate::utils::Utils::check_frozen_fields(true, false, false, false, false, true); - assert_eq!(r, Err(ContractError::VerifiedFieldFrozen)); + assert!(r.is_ok()); } #[test] @@ -92,12 +121,17 @@ fn unit_freeze_verified_no_changes_ok() { assert!(r.is_ok()); } +#[test] +fn major_metadata_fields_are_defined() { + assert_eq!(MAJOR_METADATA_FIELDS, ["name", "website", "metadata_cid"]); +} + // ═══════════════════════════════════════════════════════════════════════════ // Integration: update_project on a verified project // ═══════════════════════════════════════════════════════════════════════════ #[test] -fn verified_project_update_name_blocked() { +fn verified_project_update_name_resets_verification() { let env = mk_env(); env.mock_all_auths(); let (client, admin) = setup_contract(&env); @@ -122,8 +156,10 @@ fn verified_project_update_name_blocked() { launch_timestamp: None, bounty_url: None, }; - let result = client.try_update_project(¶ms); - assert_eq!(result, Err(Ok(ContractError::VerifiedFieldFrozen.into()))); + let project = client.update_project(¶ms); + assert_eq!(project.name, SorobanString::from_str(&env, "NewName")); + assert_eq!(project.verification_status, VerificationStatus::Unverified); + assert!(has_verification_reset_event(&env, project_id, "name")); } #[test] @@ -211,7 +247,7 @@ fn verified_project_update_logo_cid_blocked() { } #[test] -fn verified_project_update_metadata_cid_blocked() { +fn verified_project_update_metadata_cid_resets_verification() { let env = mk_env(); env.mock_all_auths(); let (client, admin) = setup_contract(&env); @@ -236,8 +272,13 @@ fn verified_project_update_metadata_cid_blocked() { launch_timestamp: None, bounty_url: None, }; - let result = client.try_update_project(¶ms); - assert_eq!(result, Err(Ok(ContractError::VerifiedFieldFrozen.into()))); + let project = client.update_project(¶ms); + assert_eq!(project.verification_status, VerificationStatus::Unverified); + assert!(has_verification_reset_event( + &env, + project_id, + "metadata_cid" + )); } // ─── Mutable fields remain editable after verification ──────────────────── @@ -266,16 +307,20 @@ fn verified_project_update_description_allowed() { bounty_url: None, }; let result = client.try_update_project(¶ms); - assert!(result.is_ok(), "description update should be allowed on verified project"); + assert!( + result.is_ok(), + "description update should be allowed on verified project" + ); let project = client.get_project(&project_id).unwrap(); assert_eq!( project.description, SorobanString::from_str(&env, "Updated description text") ); + assert_eq!(project.verification_status, VerificationStatus::Verified); } #[test] -fn verified_project_update_website_allowed() { +fn verified_project_update_website_resets_verification() { let env = mk_env(); env.mock_all_auths(); let (client, admin) = setup_contract(&env); @@ -289,7 +334,10 @@ fn verified_project_update_website_allowed() { slug: None, description: None, category: None, - website: Some(Some(SorobanString::from_str(&env, "https://newsite.example.com"))), + website: Some(Some(SorobanString::from_str( + &env, + "https://newsite.example.com", + ))), logo_cid: None, metadata_cid: None, tags: None, @@ -297,8 +345,9 @@ fn verified_project_update_website_allowed() { launch_timestamp: None, bounty_url: None, }; - let result = client.try_update_project(¶ms); - assert!(result.is_ok(), "website update should be allowed on verified project"); + let project = client.update_project(¶ms); + assert_eq!(project.verification_status, VerificationStatus::Unverified); + assert!(has_verification_reset_event(&env, project_id, "website")); } #[test] @@ -409,7 +458,10 @@ fn unverified_project_all_fields_mutable() { bounty_url: None, }; let result = client.try_update_project(¶ms); - assert!(result.is_ok(), "unverified project should allow all field updates"); + assert!( + result.is_ok(), + "unverified project should allow all field updates" + ); } // ─── Pending-verification project: frozen fields still mutable ──────────── @@ -451,4 +503,3 @@ fn pending_verification_project_fields_are_mutable() { "fields should be mutable while verification is only Pending" ); } - diff --git a/dongle-smartcontract/src/utils.rs b/dongle-smartcontract/src/utils.rs index 5a945b1b..1a98cc5f 100644 --- a/dongle-smartcontract/src/utils.rs +++ b/dongle-smartcontract/src/utils.rs @@ -304,23 +304,18 @@ impl Utils { /// Enforces the **metadata freeze policy** for verified projects. /// - /// After a project reaches `VerificationStatus::Verified`, the following - /// identity-critical fields are **frozen** and may not be changed without - /// first losing verification (i.e. the admin revokes or rejects the - /// current verification record): + /// After a project reaches `VerificationStatus::Verified`, some + /// identity-critical fields remain **frozen** and may not be changed + /// without first losing verification. /// /// | Frozen field | Reason | /// |-----------------|-------------------------------------------------------| - /// | `name` | Public identity anchor; changing it would confuse | - /// | | users who trusted the verified name. | /// | `slug` | URL-stable identifier; links would break or spoof. | /// | `category` | Verification may be category-specific. | /// | `logo_cid` | Logo is part of the verified visual identity. | - /// | `metadata_cid` | Metadata CID contains the evidence audited during | - /// | | the verification review. | /// - /// Fields that remain **mutable** after verification: - /// `description`, `website`, `tags`, `social_links`, `launch_timestamp`. + /// Major metadata fields (`name`, `website`, `metadata_cid`) are mutable, + /// but changing them resets verification status to `Unverified`. /// /// ## Parameters /// - `is_verified` – pass `true` when `project.verification_status == Verified`. @@ -334,16 +329,16 @@ impl Utils { /// would be mutated, `Ok(())` otherwise. pub fn check_frozen_fields( is_verified: bool, - name_changed: bool, + _name_changed: bool, slug_changed: bool, category_changed: bool, logo_cid_changed: bool, - metadata_cid_changed: bool, + _metadata_cid_changed: bool, ) -> Result<(), ContractError> { if !is_verified { return Ok(()); } - if name_changed || slug_changed || category_changed || logo_cid_changed || metadata_cid_changed { + if slug_changed || category_changed || logo_cid_changed { return Err(ContractError::VerifiedFieldFrozen); } Ok(())