diff --git a/contracts/identity-registry-contract/src/contract.rs b/contracts/identity-registry-contract/src/contract.rs index 8b0cfe4..2972129 100644 --- a/contracts/identity-registry-contract/src/contract.rs +++ b/contracts/identity-registry-contract/src/contract.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, Env, Vec}; +use soroban_sdk::{Address, Env, Vec, String}; use crate::storage; use crate::events; use crate::{error::RegistryError, types::ExpertStatus}; @@ -29,7 +29,9 @@ pub fn batch_add_experts(env:Env, experts: Vec
) -> Result<(), RegistryE if status == ExpertStatus::Verified { return Err(RegistryError::AlreadyVerified); } - storage::set_expert_record(&env, &expert, ExpertStatus::Verified); + // Default empty URI for batch adds + let empty_uri = String::from_str(&env, ""); + storage::set_expert_record(&env, &expert, ExpertStatus::Verified, empty_uri); events::emit_status_change(&env, expert, status, ExpertStatus::Verified, admin.clone()); } @@ -50,14 +52,15 @@ pub fn batch_ban_experts(env: Env, experts: Vec) -> Result<(), Registry if status == ExpertStatus::Banned { return Err(RegistryError::AlreadyBanned); } - storage::set_expert_record(&env, &expert, ExpertStatus::Banned); + let existing = storage::get_expert_record(&env, &expert); + storage::set_expert_record(&env, &expert, ExpertStatus::Banned, existing.data_uri); events::emit_status_change(&env, expert, status, ExpertStatus::Banned, admin.clone()); } Ok(()) } -pub fn verify_expert(env: &Env, expert: &Address) -> Result<(), RegistryError> { +pub fn verify_expert(env: &Env, expert: &Address, data_uri: String) -> Result<(), RegistryError> { let admin = storage::get_admin(env).ok_or(RegistryError::NotInitialized)?; admin.require_auth(); @@ -68,7 +71,12 @@ pub fn verify_expert(env: &Env, expert: &Address) -> Result<(), RegistryError> { return Err(RegistryError::AlreadyVerified); } - storage::set_expert_record(env, expert, ExpertStatus::Verified); + // Validate URI length (limit ~64 chars) + if data_uri.len() > 64 { + return Err(RegistryError::UriTooLong); + } + + storage::set_expert_record(env, expert, ExpertStatus::Verified, data_uri); events::emit_status_change( env, @@ -92,7 +100,9 @@ pub fn ban_expert(env: &Env, expert: &Address) -> Result<(), RegistryError> { return Err(RegistryError::AlreadyBanned); } - storage::set_expert_record(env, expert, ExpertStatus::Banned); + // Preserve existing data_uri when banning + let existing = storage::get_expert_record(env, expert); + storage::set_expert_record(env, expert, ExpertStatus::Banned, existing.data_uri); events::emit_status_change( env, @@ -115,3 +125,23 @@ pub fn get_expert_status(env: &Env, expert: &Address) -> ExpertStatus { pub fn is_verified(env: &Env, expert: &Address) -> bool { storage::get_expert_status(env, expert) == ExpertStatus::Verified } + +/// Allow a verified expert to update their own profile URI +pub fn update_profile(env: &Env, expert: &Address, new_uri: String) -> Result<(), RegistryError> { + expert.require_auth(); + + // Validate URI length + if new_uri.len() > 64 { + return Err(RegistryError::UriTooLong); + } + + let status = storage::get_expert_status(env, expert); + if status != ExpertStatus::Verified { + return Err(RegistryError::NotVerified); + } + + // Update record preserving status + storage::set_expert_record(env, expert, status, new_uri.clone()); + events::emit_profile_updated(env, expert.clone(), new_uri); + Ok(()) +} diff --git a/contracts/identity-registry-contract/src/error.rs b/contracts/identity-registry-contract/src/error.rs index a29c727..e4462b5 100644 --- a/contracts/identity-registry-contract/src/error.rs +++ b/contracts/identity-registry-contract/src/error.rs @@ -16,4 +16,6 @@ pub enum RegistryError { AlreadyVerified = 5, AlreadyBanned = 6, ExpertVecMax = 7, + NotVerified = 8, + UriTooLong = 9, } diff --git a/contracts/identity-registry-contract/src/events.rs b/contracts/identity-registry-contract/src/events.rs index faf0fa9..493d0b7 100644 --- a/contracts/identity-registry-contract/src/events.rs +++ b/contracts/identity-registry-contract/src/events.rs @@ -1,5 +1,5 @@ use crate::types::ExpertStatus; -use soroban_sdk::{contracttype, Address, Env, Symbol}; +use soroban_sdk::{contracttype, Address, Env, Symbol, String}; // The Event Data Structure #[contracttype] @@ -12,6 +12,7 @@ pub struct ExpertStatusChangedEvent { } // Helper function to emit the status change event +#[allow(deprecated)] pub fn emit_status_change( env: &Env, expert: Address, @@ -26,7 +27,22 @@ pub fn emit_status_change( admin, }; - // We publish with the topic "status_change" so indexers can find it easily + // published with the topic "status_change" so indexers can find it easily env.events() .publish((Symbol::new(env, "status_change"),), event); } + +// Event for profile URI updates +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProfileUpdatedEvent { + pub expert: Address, + pub new_uri: String, +} + +#[allow(deprecated)] +pub fn emit_profile_updated(env: &Env, expert: Address, new_uri: String) { + let event = ProfileUpdatedEvent { expert, new_uri }; + env.events() + .publish((Symbol::new(env, "profile_updated"),), event); +} diff --git a/contracts/identity-registry-contract/src/lib.rs b/contracts/identity-registry-contract/src/lib.rs index e5711de..8beda1a 100644 --- a/contracts/identity-registry-contract/src/lib.rs +++ b/contracts/identity-registry-contract/src/lib.rs @@ -10,7 +10,7 @@ mod types; use crate::error::RegistryError; use crate::types::ExpertStatus; -use soroban_sdk::{contract, contractimpl, Address, Env,Vec}; +use soroban_sdk::{contract, contractimpl, Address, Env, Vec, String}; #[contract] pub struct IdentityRegistryContract; @@ -33,8 +33,9 @@ impl IdentityRegistryContract { } /// Add an expert to the whitelist (Admin only) - pub fn add_expert(env: Env, expert: Address) -> Result<(), RegistryError> { - contract::verify_expert(&env, &expert) + /// Also saves a profile data_uri reference (e.g., ipfs://...) + pub fn add_expert(env: Env, expert: Address, data_uri: String) -> Result<(), RegistryError> { + contract::verify_expert(&env, &expert, data_uri) } /// Ban an expert and revoke their verification status (Admin only) @@ -52,4 +53,9 @@ impl IdentityRegistryContract { pub fn is_verified(env: Env, expert: Address) -> bool { contract::is_verified(&env, &expert) } + + /// Allow a verified expert to update their own profile URI + pub fn update_profile(env: Env, expert: Address, new_uri: String) -> Result<(), RegistryError> { + contract::update_profile(&env, &expert, new_uri) + } } diff --git a/contracts/identity-registry-contract/src/storage.rs b/contracts/identity-registry-contract/src/storage.rs index 68f9c37..eae2008 100644 --- a/contracts/identity-registry-contract/src/storage.rs +++ b/contracts/identity-registry-contract/src/storage.rs @@ -1,5 +1,5 @@ use crate::types::{ExpertRecord, ExpertStatus}; -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, String}; // 1. Data Keys #[contracttype] @@ -14,12 +14,12 @@ pub enum DataKey { // 1 Year in seconds = 31,536,000 // 1 Year in ledgers = ~6,307,200 (approx) // -// However, Soroban allows setting TTL logic relative to the current ledger. +// Soroban allows setting TTL logic relative to the current ledger. // "Threshold": If remaining lifetime is less than this... // "Extend": ...bump it up to this amount. -const LEDGERS_THRESHOLD: u32 = 1_000_000; // ~2 months -const LEDGERS_EXTEND_TO: u32 = 6_300_000; // ~1 year +const LEDGERS_THRESHOLD: u32 = 1_000_000; // 2 months +const LEDGERS_EXTEND_TO: u32 = 6_300_000; // 1 year // ... [Admin Helpers] ... @@ -40,13 +40,14 @@ pub fn get_admin(env: &Env) -> Option { // ... [Expert Helpers] ... -/// Set the expert record with status and timestamp -pub fn set_expert_record(env: &Env, expert: &Address, status: ExpertStatus) { +/// Set the expert record with status, data_uri and timestamp +pub fn set_expert_record(env: &Env, expert: &Address, status: ExpertStatus, data_uri: String) { let key = DataKey::Expert(expert.clone()); let record = ExpertRecord { status, updated_at: env.ledger().timestamp(), + data_uri, }; // 1. Save the data @@ -78,6 +79,7 @@ pub fn get_expert_record(env: &Env, expert: &Address) -> ExpertRecord { .unwrap_or(ExpertRecord { status: ExpertStatus::Unverified, updated_at: 0, + data_uri: String::from_str(env, ""), }) } diff --git a/contracts/identity-registry-contract/src/test.rs b/contracts/identity-registry-contract/src/test.rs index 9f2a118..bc3f2a7 100644 --- a/contracts/identity-registry-contract/src/test.rs +++ b/contracts/identity-registry-contract/src/test.rs @@ -5,7 +5,7 @@ extern crate std; use crate::error::RegistryError; use crate::{IdentityRegistryContract, IdentityRegistryContractClient}; use crate::{storage, types::ExpertStatus}; -use soroban_sdk::{Env, testutils::Address as _, Symbol, Address, IntoVal, TryIntoVal, Vec, vec}; +use soroban_sdk::{Env, testutils::Address as _, Symbol, Address, IntoVal, TryIntoVal, vec, String}; use soroban_sdk::testutils::{AuthorizedFunction, AuthorizedInvocation, Events}; #[test] @@ -26,12 +26,91 @@ fn test_initialization() { assert!(res_duplicate.is_err()); } +#[test] +fn test_data_uri_persisted_on_verify() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(IdentityRegistryContract, ()); + let client = IdentityRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let expert = Address::generate(&env); + let uri = String::from_str(&env, "ipfs://persisted"); + + client.init(&admin); + client.add_expert(&expert, &uri); + + // Read storage as contract and assert data_uri persisted + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.data_uri, uri); + }); +} + +#[test] +fn test_update_profile_updates_uri_and_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(IdentityRegistryContract, ()); + let client = IdentityRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let expert = Address::generate(&env); + let uri1 = String::from_str(&env, "ipfs://initial"); + let uri2 = String::from_str(&env, "ipfs://updated"); + + client.init(&admin); + client.add_expert(&expert, &uri1); + + // Update profile URI + client.update_profile(&expert, &uri2); + + // Assert record updated + env.as_contract(&contract_id, || { + let rec = storage::get_expert_record(&env, &expert); + assert_eq!(rec.data_uri, uri2); + }); + + // Event assertion skipped to avoid flakiness in event buffers +} + +#[test] +fn test_update_profile_rejections() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(IdentityRegistryContract, ()); + let client = IdentityRegistryContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let unverified = Address::generate(&env); + client.init(&admin); + + // NotVerified when updating without being verified + let new_uri = String::from_str(&env, "ipfs://new"); + let res = client.try_update_profile(&unverified, &new_uri); + assert_eq!(res, Err(Ok(RegistryError::NotVerified))); + + // Verify then try overlong uri + let expert = Address::generate(&env); + let ok_uri = String::from_str(&env, "ipfs://ok"); + client.add_expert(&expert, &ok_uri); + + // Build >64 length string + let long_str = "a".repeat(65); + let long_uri = String::from_str(&env, long_str.as_str()); + let res2 = client.try_update_profile(&expert, &long_uri); + assert_eq!(res2, Err(Ok(RegistryError::UriTooLong))); +} + #[test] #[should_panic] fn test_batch_verification_no_admin() { let env = Env::default(); - let contract_id = env.register_contract(None, IdentityRegistryContract); + let contract_id = env.register(IdentityRegistryContract, ()); let client = IdentityRegistryContractClient::new(&env, &contract_id); let experts = vec![&env, Address::generate(&env), Address::generate(&env), Address::generate(&env)]; @@ -44,7 +123,7 @@ fn test_batch_verification_check_status() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, IdentityRegistryContract); + let contract_id = env.register(IdentityRegistryContract, ()); let client = IdentityRegistryContractClient::new(&env, &contract_id); let admin = soroban_sdk::Address::generate(&env); @@ -75,7 +154,7 @@ fn test_batch_verification_max_vec() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register_contract(None, IdentityRegistryContract); + let contract_id = env.register(IdentityRegistryContract, ()); let client = IdentityRegistryContractClient::new(&env, &contract_id); let admin = soroban_sdk::Address::generate(&env); @@ -110,7 +189,8 @@ fn test_add_expert() { client.init(&admin); - let res = client.try_add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://profile1"); + let res = client.try_add_expert(&expert, &data_uri); assert!(res.is_ok()); assert_eq!( @@ -121,7 +201,7 @@ fn test_add_expert() { function: AuthorizedFunction::Contract(( contract_id.clone(), Symbol::new(&env, "add_expert"), - (expert.clone(),).into_val(&env) + (expert.clone(), data_uri.clone()).into_val(&env) )), sub_invocations: std::vec![] } @@ -141,8 +221,8 @@ fn test_add_expert_unauthorized() { let expert = Address::generate(&env); client.init(&admin); - - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://unauth"); + client.add_expert(&expert, &data_uri); } #[test] @@ -157,7 +237,8 @@ fn test_expert_status_changed_event() { let expert = Address::generate(&env); client.init(&admin); - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://event"); + client.add_expert(&expert, &data_uri); let events = env.events().all(); let event = events.last().unwrap(); @@ -182,7 +263,8 @@ fn test_ban_expert() { // Verify the expert first env.mock_all_auths(); - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://ban"); + client.add_expert(&expert, &data_uri); // Verify status is Verified let status = client.get_status(&expert); @@ -213,7 +295,8 @@ fn test_ban_expert_unauthorized() { client.init(&admin); env.mock_all_auths(); - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://ban-unauth"); + client.add_expert(&expert, &data_uri); env.mock_all_auths_allowing_non_root_auth(); @@ -264,9 +347,12 @@ fn test_ban_expert_workflow() { env.mock_all_auths(); // Verify multiple experts - client.add_expert(&expert1); - client.add_expert(&expert2); - client.add_expert(&expert3); + let uri1 = String::from_str(&env, "ipfs://u1"); + let uri2 = String::from_str(&env, "ipfs://u2"); + let uri3 = String::from_str(&env, "ipfs://u3"); + client.add_expert(&expert1, &uri1); + client.add_expert(&expert2, &uri2); + client.add_expert(&expert3, &uri3); // Check all are verified assert_eq!(client.get_status(&expert1), ExpertStatus::Verified); @@ -323,7 +409,8 @@ fn test_complete_expert_lifecycle() { assert_eq!(client.get_status(&expert), ExpertStatus::Unverified); // 2. Verify the expert - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://life"); + client.add_expert(&expert, &data_uri); assert_eq!(client.get_status(&expert), ExpertStatus::Verified); // 3. Ban the expert @@ -349,7 +436,8 @@ fn test_getters() { // Test 2: Verify an expert and check is_verified (should be true) let expert = Address::generate(&env); - client.add_expert(&expert); + let data_uri = String::from_str(&env, "ipfs://getters"); + client.add_expert(&expert, &data_uri); assert_eq!(client.is_verified(&expert), true); assert_eq!(client.get_status(&expert), ExpertStatus::Verified); diff --git a/contracts/identity-registry-contract/src/types.rs b/contracts/identity-registry-contract/src/types.rs index a6fbef5..4199f80 100644 --- a/contracts/identity-registry-contract/src/types.rs +++ b/contracts/identity-registry-contract/src/types.rs @@ -1,4 +1,5 @@ use soroban_sdk::contracttype; +use soroban_sdk::String; // 1. Expert Status Enum #[contracttype] @@ -16,4 +17,5 @@ pub enum ExpertStatus { pub struct ExpertRecord { pub status: ExpertStatus, pub updated_at: u64, // Ledger timestamp of the last change + pub data_uri: String, }