Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dongle-smartcontract/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
34 changes: 33 additions & 1 deletion dongle-smartcontract/src/events.rs
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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<String>,
pub timestamp: u64,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProjectArchivedEvent {
Expand Down Expand Up @@ -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<String>,
) {
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,
Expand Down
65 changes: 53 additions & 12 deletions dongle-smartcontract/src/project_registry.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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<String> = 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
Expand Down Expand Up @@ -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, crate::types::VerificationRecord>(
&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;
}
Expand Down Expand Up @@ -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)
Expand Down
78 changes: 59 additions & 19 deletions dongle-smartcontract/src/tests/verified_freeze.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 ─────────────────────────────────────────────────────────────────

Expand All @@ -44,6 +51,28 @@ fn approve_verification(
client.approve_verification(&project_id, admin);
}

fn decode_event<T: soroban_sdk::TryFromVal<Env, Val>>(env: &Env, data: &Val) -> Option<T> {
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::<VerificationStatusResetEvent>(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
// ═══════════════════════════════════════════════════════════════════════════
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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);
Expand All @@ -123,8 +157,10 @@ fn verified_project_update_name_blocked() {
launch_timestamp: None,
bounty_url: None,
};
let result = client.try_update_project(&params);
assert_eq!(result, Err(Ok(ContractError::VerifiedFieldFrozen.into())));
let project = client.update_project(&params);
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]
Expand Down Expand Up @@ -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);
Expand All @@ -241,8 +277,13 @@ fn verified_project_update_metadata_cid_blocked() {
launch_timestamp: None,
bounty_url: None,
};
let result = client.try_update_project(&params);
assert_eq!(result, Err(Ok(ContractError::VerifiedFieldFrozen.into())));
let project = client.update_project(&params);
assert_eq!(project.verification_status, VerificationStatus::Unverified);
assert!(has_verification_reset_event(
&env,
project_id,
"metadata_cid"
));
}

// ─── Mutable fields remain editable after verification ────────────────────
Expand Down Expand Up @@ -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);
Expand All @@ -309,11 +351,9 @@ fn verified_project_update_website_allowed() {
launch_timestamp: None,
bounty_url: None,
};
let result = client.try_update_project(&params);
assert!(
result.is_ok(),
"website update should be allowed on verified project"
);
let project = client.update_project(&params);
assert_eq!(project.verification_status, VerificationStatus::Unverified);
assert!(has_verification_reset_event(&env, project_id, "website"));
}

#[test]
Expand Down
26 changes: 8 additions & 18 deletions dongle-smartcontract/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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(())
Expand Down
Loading