From 50f760bf03fccf8b41d608c56a8b4b2a2db6119d Mon Sep 17 00:00:00 2001 From: aymide1ee Date: Mon, 1 Jun 2026 20:35:26 +0000 Subject: [PATCH] feat: add verification expiry (#131) - Add expires_at: Option to VerificationRecord - Add VerificationConfig type for admin-configurable duration - Set expires_at on approve_verification (default 365 days) - Add is_verification_active read API (checks expiry at call time) - Add set_verification_duration admin function - Add renew_verification owner function (requires fee payment) - Add get_verification_duration query - Add VerificationRenewedEvent - Add verification_expiry test module (8 tests) --- dongle-smartcontract/src/constants.rs | 5 + dongle-smartcontract/src/events.rs | 28 ++ dongle-smartcontract/src/lib.rs | 30 ++ dongle-smartcontract/src/storage_keys.rs | 2 + dongle-smartcontract/src/tests/mod.rs | 1 + .../src/tests/verification_expiry.rs | 264 ++++++++++++++++++ dongle-smartcontract/src/types.rs | 11 + .../src/verification_registry.rs | 85 +++++- 8 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 dongle-smartcontract/src/tests/verification_expiry.rs diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index b8d1c51..ddb4b7d 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -68,3 +68,8 @@ pub const LEDGER_BUMP_PROJECT: u32 = LEDGER_THRESHOLD_PROJECT; pub const LEDGER_BUMP_REVIEW: u32 = LEDGER_THRESHOLD_REVIEW; pub const LEDGER_BUMP_VERIFICATION: u32 = LEDGER_THRESHOLD_VERIFICATION; pub const LEDGER_BUMP_USER: u32 = LEDGER_THRESHOLD_USER; + +// ── Verification Expiry ─────────────────────────────────────────────────────── + +/// Default verification validity period: 365 days in seconds. +pub const DEFAULT_VERIFICATION_DURATION_SECS: u64 = 365 * 24 * 60 * 60; diff --git a/dongle-smartcontract/src/events.rs b/dongle-smartcontract/src/events.rs index fac4d3b..ada8e7d 100644 --- a/dongle-smartcontract/src/events.rs +++ b/dongle-smartcontract/src/events.rs @@ -63,6 +63,16 @@ pub struct VerificationRevokedEvent { pub timestamp: u64, } +/// Emitted when a verification is renewed by the project owner. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerificationRenewedEvent { + pub project_id: u64, + pub caller: Address, + pub expires_at: Option, + pub timestamp: u64, +} + /// Emitted when project ownership is transferred. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -273,6 +283,24 @@ pub fn publish_verification_revoked_event( ); } +pub fn publish_verification_renewed_event( + env: &Env, + project_id: u64, + caller: Address, + expires_at: Option, +) { + let event_data = VerificationRenewedEvent { + project_id, + caller, + expires_at, + timestamp: env.ledger().timestamp(), + }; + env.events().publish( + (symbol_short!("VERIFY"), symbol_short!("RENEWED"), project_id), + event_data, + ); +} + // ── Admin events ────────────────────────────────────────────────────────────── pub fn publish_ownership_transferred_event( diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 80188a3..736a2d5 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -278,6 +278,36 @@ impl DongleContract { VerificationRegistry::get_verifications_batch(&env, ids) } + /// Returns true if the verification is Verified and has not expired. + pub fn is_verification_active(env: Env, project_id: u64) -> bool { + VerificationRegistry::is_verification_active(&env, project_id) + } + + /// Admin: configure how long (seconds) a verification stays valid after approval. + /// Pass `None` to disable expiry entirely. + pub fn set_verification_duration( + env: Env, + admin: Address, + duration_secs: Option, + ) -> Result<(), ContractError> { + VerificationRegistry::set_verification_duration(&env, admin, duration_secs) + } + + /// Owner: renew an existing verified project, extending its expiry from now. + /// Requires a new fee payment before calling. + pub fn renew_verification( + env: Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + VerificationRegistry::renew_verification(&env, project_id, caller) + } + + /// Returns the currently configured verification duration in seconds (None = no expiry). + pub fn get_verification_duration(env: Env) -> Option { + VerificationRegistry::get_verification_duration(&env) + } + // --- Fee Manager --- pub fn set_fee( diff --git a/dongle-smartcontract/src/storage_keys.rs b/dongle-smartcontract/src/storage_keys.rs index 7a240c8..174410a 100644 --- a/dongle-smartcontract/src/storage_keys.rs +++ b/dongle-smartcontract/src/storage_keys.rs @@ -44,4 +44,6 @@ pub enum StorageKey { PendingTransfer(u64), /// List of project IDs by category. CategoryProjects(String), + /// Admin-configurable verification duration settings. + VerificationConfig, } diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index c0c4480..7b67a24 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -14,6 +14,7 @@ mod verification; mod authorization; mod events; mod pagination; +mod verification_expiry; // Test infrastructure pub mod fixtures; diff --git a/dongle-smartcontract/src/tests/verification_expiry.rs b/dongle-smartcontract/src/tests/verification_expiry.rs new file mode 100644 index 0000000..0e42809 --- /dev/null +++ b/dongle-smartcontract/src/tests/verification_expiry.rs @@ -0,0 +1,264 @@ +//! Tests for verification expiry: active, expired, and renewed verification. + +use crate::errors::ContractError; +use crate::types::{ProjectRegistrationParams, VerificationStatus}; +use crate::DongleContract; +use crate::DongleContractClient; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn setup(env: &Env) -> (DongleContractClient<'_>, Address, Address) { + let contract_id = env.register(DongleContract, ()); + let client = DongleContractClient::new(env, &contract_id); + let admin = Address::generate(env); + client.initialize(&admin); + (client, admin, Address::generate(env)) +} + +/// Register a project, configure a fee token, mint tokens, pay the fee, and +/// request verification. Returns the project_id. +fn setup_verified_project( + client: &DongleContractClient<'_>, + env: &Env, + admin: &Address, + owner: &Address, + name: &str, +) -> u64 { + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(env, name), + description: String::from_str(env, "Test description for expiry tests"), + category: String::from_str(env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + let token_admin = Address::generate(env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = soroban_sdk::token::StellarAssetClient::new(env, &token_address); + token_client.mint(owner, &1000); + client.set_fee(admin, &Some(token_address.clone()), &100, admin); + client.pay_fee(owner, &project_id, &Some(token_address)); + client.request_verification(&project_id, owner, &String::from_str(env, "ipfs://evidence")); + client.approve_verification(&project_id, admin); + + project_id +} + +/// Pay the verification fee again for an existing project (for renewal). +fn pay_fee_again( + client: &DongleContractClient<'_>, + env: &Env, + admin: &Address, + owner: &Address, + project_id: u64, +) { + let token_admin = Address::generate(env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = soroban_sdk::token::StellarAssetClient::new(env, &token_address); + token_client.mint(owner, &1000); + client.set_fee(admin, &Some(token_address.clone()), &100, admin); + client.pay_fee(owner, &project_id, &Some(token_address)); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// After approval the record carries an expires_at and is_verification_active returns true. +#[test] +fn test_active_verification() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + let project_id = setup_verified_project(&client, &env, &admin, &owner, "Active Project"); + + // is_verification_active should be true immediately after approval + assert!(client.is_verification_active(&project_id)); + + // The record should have an expires_at set + let record = client.get_verification(&project_id); + assert_eq!(record.status, VerificationStatus::Verified); + assert!(record.expires_at.is_some()); +} + +/// After the ledger timestamp advances past expires_at, is_verification_active returns false. +#[test] +fn test_expired_verification() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + // Set a very short duration: 100 seconds + client.set_verification_duration(&admin, &Some(100u64)); + + let project_id = setup_verified_project(&client, &env, &admin, &owner, "Expiring Project"); + + // Advance ledger time past the expiry + env.ledger().with_mut(|li| { + li.timestamp += 200; // 200 seconds later + }); + + assert!(!client.is_verification_active(&project_id)); + + // The record status is still Verified (expiry is checked at read time, not written back) + let record = client.get_verification(&project_id); + assert_eq!(record.status, VerificationStatus::Verified); +} + +/// After expiry, renewing the verification extends expires_at and makes it active again. +#[test] +fn test_renew_verification() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + // Short duration so we can expire it quickly + client.set_verification_duration(&admin, &Some(100u64)); + + let project_id = setup_verified_project(&client, &env, &admin, &owner, "Renewable Project"); + + // Expire it + env.ledger().with_mut(|li| { + li.timestamp += 200; + }); + assert!(!client.is_verification_active(&project_id)); + + // Pay fee and renew + pay_fee_again(&client, &env, &admin, &owner, project_id); + client.renew_verification(&project_id, &owner); + + // Should be active again + assert!(client.is_verification_active(&project_id)); + + // expires_at should be updated + let record = client.get_verification(&project_id); + let now = env.ledger().timestamp(); + let expires_at = record.expires_at.unwrap(); + assert!(expires_at > now); +} + +/// Admin can disable expiry (duration = None); verification never expires. +#[test] +fn test_no_expiry_when_duration_is_none() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + // Disable expiry + client.set_verification_duration(&admin, &None); + + let project_id = setup_verified_project(&client, &env, &admin, &owner, "No Expiry Project"); + + // Advance time by a very large amount + env.ledger().with_mut(|li| { + li.timestamp += 10_000_000; + }); + + assert!(client.is_verification_active(&project_id)); + + let record = client.get_verification(&project_id); + assert!(record.expires_at.is_none()); +} + +/// get_verification_duration returns the configured value. +#[test] +fn test_get_verification_duration() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, _owner) = setup(&env); + + // Default should be 365 days in seconds + let default_duration = client.get_verification_duration(); + assert_eq!(default_duration, Some(365 * 24 * 60 * 60)); + + // Set a custom duration + client.set_verification_duration(&admin, &Some(7200u64)); + assert_eq!(client.get_verification_duration(), Some(7200u64)); + + // Disable expiry + client.set_verification_duration(&admin, &None); + assert_eq!(client.get_verification_duration(), None); +} + +/// Only admin can call set_verification_duration. +#[test] +fn test_set_verification_duration_non_admin_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _owner) = setup(&env); + + let non_admin = Address::generate(&env); + let result = client.try_set_verification_duration(&non_admin, &Some(3600u64)); + assert_eq!(result, Err(Ok(ContractError::AdminOnly))); +} + +/// renew_verification fails if the project is not in Verified status. +#[test] +fn test_renew_non_verified_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Pending Project"), + description: String::from_str(&env, "Test description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + token_client.mint(&owner, &1000); + client.set_fee(&admin, &Some(token_address.clone()), &100, &admin); + client.pay_fee(&owner, &project_id, &Some(token_address.clone())); + client.request_verification(&project_id, &owner, &String::from_str(&env, "ipfs://ev")); + + // Project is Pending, not Verified — renew should fail + // Pay fee first so we don't hit InsufficientFee + let token_admin2 = Address::generate(&env); + let token_address2 = env + .register_stellar_asset_contract_v2(token_admin2) + .address(); + let token_client2 = soroban_sdk::token::StellarAssetClient::new(&env, &token_address2); + token_client2.mint(&owner, &1000); + client.set_fee(&admin, &Some(token_address2.clone()), &100, &admin); + client.pay_fee(&owner, &project_id, &Some(token_address2)); + + let result = client.try_renew_verification(&project_id, &owner); + assert_eq!(result, Err(Ok(ContractError::InvalidStatusTransition))); +} + +/// is_verification_active returns false for an unverified project. +#[test] +fn test_is_active_unverified_project() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, owner) = setup(&env); + + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Unverified Project"), + description: String::from_str(&env, "Test description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + assert!(!client.is_verification_active(&project_id)); +} diff --git a/dongle-smartcontract/src/types.rs b/dongle-smartcontract/src/types.rs index 3eda94d..9734809 100644 --- a/dongle-smartcontract/src/types.rs +++ b/dongle-smartcontract/src/types.rs @@ -123,6 +123,17 @@ pub struct VerificationRecord { pub timestamp: u64, pub fee_amount: u128, pub revoke_reason: Option, + /// Unix timestamp (seconds) when the verification expires. None = never expires. + pub expires_at: Option, +} + +/// Admin-configurable verification settings. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VerificationConfig { + /// How long (in seconds) a verification stays active after approval. + /// None means verifications never expire. + pub duration_secs: Option, } /// Fee configuration for contract operations diff --git a/dongle-smartcontract/src/verification_registry.rs b/dongle-smartcontract/src/verification_registry.rs index d5787d7..5368ffb 100644 --- a/dongle-smartcontract/src/verification_registry.rs +++ b/dongle-smartcontract/src/verification_registry.rs @@ -1,16 +1,17 @@ //! Verification requests with ownership and fee checks, events, and state machine. use crate::auth::{require_admin_auth, require_owner_auth}; -use crate::constants::MAX_CID_LEN; +use crate::constants::{DEFAULT_VERIFICATION_DURATION_SECS, MAX_CID_LEN}; use crate::errors::ContractError; use crate::events::{ publish_verification_approved_event, publish_verification_rejected_event, publish_verification_requested_event, publish_verification_revoked_event, + publish_verification_renewed_event, }; use crate::fee_manager::FeeManager; use crate::project_registry::ProjectRegistry; use crate::storage_keys::StorageKey; -use crate::types::{VerificationRecord, VerificationStatus}; +use crate::types::{VerificationConfig, VerificationRecord, VerificationStatus}; use crate::utils::Utils; use soroban_sdk::{Address, Env, String, Vec}; @@ -185,6 +186,7 @@ impl VerificationRegistry { timestamp: now, fee_amount: config.verification_fee, revoke_reason: None, + expires_at: None, }; env.storage() @@ -224,8 +226,13 @@ impl VerificationRegistry { let now = env.ledger().timestamp(); + // Compute expiry from configured duration (default 365 days) + let duration = Self::get_verification_duration(env); + let expires_at = duration.map(|d| now + d); + // Update record record.status = VerificationStatus::Verified; + record.expires_at = expires_at; env.storage() .persistent() .set(&StorageKey::Verification(project_id), &record); @@ -310,6 +317,80 @@ impl VerificationRegistry { out } + /// Returns true if the verification record exists, is Verified, and has not expired. + pub fn is_verification_active(env: &Env, project_id: u64) -> bool { + let record: VerificationRecord = match env + .storage() + .persistent() + .get(&StorageKey::Verification(project_id)) + { + Some(r) => r, + None => return false, + }; + if record.status != VerificationStatus::Verified { + return false; + } + match record.expires_at { + None => true, + Some(exp) => env.ledger().timestamp() < exp, + } + } + + /// Admin: set the verification duration (seconds). Pass `None` to disable expiry. + pub fn set_verification_duration( + env: &Env, + admin: Address, + duration_secs: Option, + ) -> Result<(), ContractError> { + require_admin_auth(env, &admin)?; + let config = VerificationConfig { duration_secs }; + env.storage() + .persistent() + .set(&StorageKey::VerificationConfig, &config); + Ok(()) + } + + /// Owner: renew an active (or expired) verification, extending `expires_at` from now. + /// The project must currently be Verified (status). Requires a new fee payment. + pub fn renew_verification( + env: &Env, + project_id: u64, + caller: Address, + ) -> Result<(), ContractError> { + let project = + ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?; + require_owner_auth(&caller, &project.owner)?; + + let mut record = Self::get_verification(env, project_id)?; + if record.status != VerificationStatus::Verified { + return Err(ContractError::InvalidStatusTransition); + } + + FeeManager::consume_fee_payment(env, project_id)?; + + let now = env.ledger().timestamp(); + let duration = Self::get_verification_duration(env); + record.expires_at = duration.map(|d| now + d); + env.storage() + .persistent() + .set(&StorageKey::Verification(project_id), &record); + + publish_verification_renewed_event(env, project_id, caller, record.expires_at); + Ok(()) + } + + /// Returns the configured duration in seconds, falling back to the default. + pub fn get_verification_duration(env: &Env) -> Option { + let config: Option = env + .storage() + .persistent() + .get(&StorageKey::VerificationConfig); + match config { + Some(c) => c.duration_secs, + None => Some(DEFAULT_VERIFICATION_DURATION_SECS), + } + } + pub fn validate_evidence_cid(evidence_cid: &String) -> Result<(), ContractError> { if evidence_cid.is_empty() { return Err(ContractError::InvalidProjectData);