diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index 42117ed5..a325ff7f 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -48,6 +48,16 @@ pub const MAX_SECURITY_CONTACT_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 4acb61f8..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, diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index 126291c0..d65f48a3 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}; @@ -248,6 +251,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, @@ -257,6 +265,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 @@ -352,19 +375,28 @@ impl ProjectRegistry { project.metadata_cid = value; } - if let Some(value) = params.tags.as_ref() { - if let Some(tags) = value { - Utils::validate_tags(tags)?; - } - } - if let Some(value) = params.social_links.as_ref() { - if let Some(social_links) = value { - Utils::validate_social_links(social_links)?; + 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; } - let tags_update = params.tags.clone(); - let social_links_update = params.social_links.clone(); + // Handle tags update if let Some(value) = params.tags { project.tags = value; } @@ -523,6 +555,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) diff --git a/dongle-smartcontract/src/tests/verified_freeze.rs b/dongle-smartcontract/src/tests/verified_freeze.rs index c7e16b0d..269990a6 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); @@ -123,8 +157,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] @@ -215,7 +251,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); @@ -241,8 +277,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 ──────────────────── @@ -281,10 +322,11 @@ fn verified_project_update_description_allowed() { 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); @@ -309,11 +351,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] diff --git a/dongle-smartcontract/src/utils.rs b/dongle-smartcontract/src/utils.rs index 9e805788..50126cdf 100644 --- a/dongle-smartcontract/src/utils.rs +++ b/dongle-smartcontract/src/utils.rs @@ -341,23 +341,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`. @@ -371,21 +366,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(())