diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..892b8fc --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +rust 1.95.0 diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index 15c53f6..6b66cc8 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -88,6 +88,7 @@ #![allow(clippy::trivially_copy_pass_by_ref)] #![allow(clippy::needless_borrow)] +use crate::score::ScoreError; use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Map, String, Symbol, Vec}; mod access_control; @@ -102,7 +103,6 @@ mod backup; mod bft_consensus; mod bridge; mod bulk_limits; -mod config; mod dos_protection; // TODO: Fix collaboration module compilation errors (pre-existing issue) // mod collaboration; @@ -110,6 +110,7 @@ mod dos_protection; // mod content_nft; // TODO: Fix content_quality module compilation errors (pre-existing issue - symbol too long) // mod content_quality; +mod config; mod emergency; mod feature_flags; mod errors; diff --git a/contracts/teachlink/src/notification.rs b/contracts/teachlink/src/notification.rs index 89d1740..e2094cc 100644 --- a/contracts/teachlink/src/notification.rs +++ b/contracts/teachlink/src/notification.rs @@ -8,6 +8,7 @@ use crate::notification_events_basic::{ NotificationDeliveredEvent, NotificationFailedEvent, NotificationPrefUpdatedEvent, NotificationScheduledEvent, }; +use crate::rate_limiting::RateLimiter; use crate::safe_stats::safe_inc_u64; use crate::storage::{ NOTIFICATION_COUNTER, NOTIFICATION_LAST_CLEANUP, NOTIFICATION_LOGS, NOTIFICATION_MAX_SIZE, @@ -19,6 +20,8 @@ use crate::types::{ NotificationPreference, NotificationSchedule, NotificationTemplate, NotificationTracking, UserNotificationSettings, }; +use soroban_sdk::symbol_short; +use soroban_sdk::Symbol; use soroban_sdk::{contracttype, vec, Address, Bytes, Env, IntoVal, Map, String, Vec}; pub use crate::config::NOTIF_BATCH_SIZE as BATCH_SIZE; @@ -35,6 +38,25 @@ pub const CLEANUP_INTERVAL_SECONDS: u64 = 3600; // 1 hour pub struct NotificationManager; impl NotificationManager { + /// Set rate limit config for notification creation (admin only) + pub fn set_notification_rate_limit( + env: &Env, + admin: Address, + max_calls: u32, + window_ledgers: u32, + endpoint: Symbol, + ) -> Result<(), BridgeError> { + admin.require_auth(); + RateLimiter::set_endpoint_config( + env, + &endpoint, + crate::rate_limiting::EndpointConfig { + max_calls, + window_ledgers, + }, + ) + .map_err(|_| BridgeError::StorageError) + } /// Initialize notification system pub fn initialize(env: &Env) -> Result<(), BridgeError> { if env.storage().instance().has(&NOTIFICATION_COUNTER) { @@ -107,6 +129,10 @@ impl NotificationManager { channel: NotificationChannel, content: NotificationContent, ) -> Result { + // Rate limit: per-user for send_notification + let endpoint = symbol_short!("NOTISEND"); + RateLimiter::check_rate_limit(env, &recipient, &endpoint)?; + let notification_id = Self::get_next_notification_id(env); // Check user preferences @@ -184,6 +210,10 @@ impl NotificationManager { content: NotificationContent, schedule: NotificationSchedule, ) -> Result { + // Rate limit: per-user for schedule_notification + let endpoint = symbol_short!("NOTISCHD"); + RateLimiter::check_rate_limit(env, &recipient, &endpoint)?; + let notification_id = Self::get_next_notification_id(env); // Validate schedule diff --git a/contracts/teachlink/src/notification_tests.rs b/contracts/teachlink/src/notification_tests.rs new file mode 100644 index 0000000..ed57c7a --- /dev/null +++ b/contracts/teachlink/src/notification_tests.rs @@ -0,0 +1,403 @@ +//! Notification System Tests +//! +//! This module contains comprehensive tests for the notification system. + +#[cfg(test)] +pub mod notification_tests { + #[test] + fn test_send_notification_rate_limit() { + let env = Env::default(); + let recipient = create_test_address(&env, 2); + let admin = create_test_address(&env, 1); + + NotificationManager::initialize(&env).unwrap(); + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Set rate limit: 2 notifications per 100 ledgers + NotificationManager::set_notification_rate_limit( + &env, + admin.clone(), + 2, + 100, + soroban_sdk::Symbol::short("notif_send"), + ).unwrap(); + + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"RL Test"), + body: Bytes::from_slice(&env, b"Body"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + // First two should succeed + assert!(NotificationManager::send_notification(&env, recipient.clone(), NotificationChannel::InApp, content.clone()).is_ok()); + assert!(NotificationManager::send_notification(&env, recipient.clone(), NotificationChannel::InApp, content.clone()).is_ok()); + // Third should fail with rate limit error + let result = NotificationManager::send_notification(&env, recipient.clone(), NotificationChannel::InApp, content.clone()); + assert!(result.is_err()); + } + + #[test] + fn test_schedule_notification_rate_limit() { + let env = Env::default(); + let recipient = create_test_address(&env, 2); + let admin = create_test_address(&env, 1); + let current_time = env.ledger().timestamp(); + let future_time = current_time + 3600; + + NotificationManager::initialize(&env).unwrap(); + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Set rate limit: 1 scheduled notification per 100 ledgers + NotificationManager::set_notification_rate_limit( + &env, + admin.clone(), + 1, + 100, + soroban_sdk::Symbol::short("notif_schedule"), + ).unwrap(); + + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"RL Sched Test"), + body: Bytes::from_slice(&env, b"Body"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + let schedule = NotificationSchedule { + notification_id: 0, + recipient: recipient.clone(), + channel: NotificationChannel::Email, + scheduled_time: future_time, + timezone: Bytes::from_slice(&env, b"UTC"), + is_recurring: false, + recurrence_pattern: 0, + max_deliveries: None, + delivery_count: 0, + }; + + // First should succeed + assert!(NotificationManager::schedule_notification(&env, recipient.clone(), NotificationChannel::Email, content.clone(), schedule.clone()).is_ok()); + // Second should fail + let result = NotificationManager::schedule_notification(&env, recipient.clone(), NotificationChannel::Email, content.clone(), schedule.clone()); + assert!(result.is_err()); + } + use crate::notification::*; + use crate::notification_types::*; + use crate::storage::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{Address, Bytes, Env, Map, String, Vec}; + + // Helper function to create test addresses + fn create_test_address(env: &Env, id: u8) -> Address { + // Use Address::generate for test addresses + Address::generate(&env) + } + + #[test] + fn test_notification_initialization() { + let env = Env::default(); + let admin = create_test_address(&env, 1); + + // Test initialization + let result = NotificationManager::initialize(&env); + assert!(result.is_ok()); + + // Verify counter is set + let counter: u64 = env.storage().instance().get(&NOTIFICATION_COUNTER).unwrap(); + assert_eq!(counter, 0); + + // Verify default templates are created + let templates: Map = env + .storage() + .instance() + .get(&NOTIFICATION_TEMPLATES) + .unwrap(); + assert!(templates.len() >= 2); // Welcome and transaction templates + } + + #[test] + fn test_send_immediate_notification() { + let env = Env::default(); + let recipient = create_test_address(&env, 2); + let admin = create_test_address(&env, 1); + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Set up user settings + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Send notification + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"Test Subject"), + body: Bytes::from_slice(&env, b"Test Body"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + let result = NotificationManager::send_notification( + &env, + recipient.clone(), + NotificationChannel::InApp, + content.clone(), + ); + + assert!(result.is_ok()); + let notification_id = result.unwrap(); + + // Verify tracking + let tracking = NotificationManager::get_notification_tracking(&env, notification_id); + assert!(tracking.is_some()); + let tracking = tracking.unwrap(); + assert_eq!(tracking.recipient, recipient); + assert_eq!(tracking.channel, NotificationChannel::InApp); + assert!(matches!( + tracking.status, + NotificationDeliveryStatus::Delivered | NotificationDeliveryStatus::Failed + )); + } + + #[test] + fn test_schedule_notification() { + let env = Env::default(); + let recipient = create_test_address(&env, 2); + let current_time = env.ledger().timestamp(); + let future_time = current_time + 3600; // 1 hour from now + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Set up user settings + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Schedule notification + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"Scheduled Test"), + body: Bytes::from_slice(&env, b"This is a scheduled notification"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + let schedule = NotificationSchedule { + notification_id: 0, + recipient: recipient.clone(), + channel: NotificationChannel::Email, + scheduled_time: future_time, + timezone: Bytes::from_slice(&env, b"UTC"), + is_recurring: false, + recurrence_pattern: 0, + max_deliveries: None, + delivery_count: 0, + }; + + let result = NotificationManager::schedule_notification( + &env, + recipient.clone(), + NotificationChannel::Email, + content, + schedule, + ); + + assert!(result.is_ok()); + let notification_id = result.unwrap(); + + // Verify tracking shows scheduled status + let tracking = NotificationManager::get_notification_tracking(&env, notification_id); + assert!(tracking.is_some()); + let tracking = tracking.unwrap(); + assert_eq!(tracking.status, NotificationDeliveryStatus::Scheduled); + } + + #[test] + fn test_process_scheduled_notifications() { + let env = Env::default(); + let recipient = create_test_address(&env, 2); + let current_time = env.ledger().timestamp(); + let past_time = current_time - 100; // Schedule in the past for immediate processing + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Set up user settings + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Schedule notification in the past + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"Past Scheduled"), + body: Bytes::from_slice(&env, b"This should be processed immediately"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + let schedule = NotificationSchedule { + notification_id: 0, + recipient: recipient.clone(), + channel: NotificationChannel::InApp, + scheduled_time: past_time, + timezone: Bytes::from_slice(&env, b"UTC"), + is_recurring: false, + recurrence_pattern: 0, + max_deliveries: None, + delivery_count: 0, + }; + + NotificationManager::schedule_notification( + &env, + recipient.clone(), + NotificationChannel::InApp, + content, + schedule, + ) + .unwrap(); + + // Process scheduled notifications + let processed_count = NotificationManager::process_scheduled_notifications(&env).unwrap(); + assert!(processed_count > 0); + } + + #[test] + fn test_update_preferences() { + let env = Env::default(); + let user = create_test_address(&env, 3); + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Create preferences + let mut preferences = Vec::new(&env); + preferences.push_back(NotificationPreference { + channel: NotificationChannel::Email, + enabled: true, + frequency_hours: 24, + quiet_hours_only: false, + urgent_only: false, + }); + preferences.push_back(NotificationPreference { + channel: NotificationChannel::SMS, + enabled: false, + frequency_hours: 1, + quiet_hours_only: true, + urgent_only: true, + }); + + // Update preferences + let result = + NotificationManager::update_preferences(&env, user.clone(), preferences.clone()); + assert!(result.is_ok()); + + // Verify preferences were stored (would need to add a getter method to fully test) + } + + #[test] + fn test_create_template() { + let env = Env::default(); + let admin = create_test_address(&env, 1); + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Create template + let name = Bytes::from_slice(&env, b"Test Template"); + let mut channels = Vec::new(&env); + channels.push_back(NotificationChannel::Email); + channels.push_back(NotificationChannel::InApp); + + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"Template Subject"), + body: Bytes::from_slice(&env, b"Template body with {{variable}}"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + let result = NotificationManager::create_template(&env, admin, name, channels, content); + assert!(result.is_ok()); + let template_id = result.unwrap(); + assert!(template_id > 0); + } + + #[test] + fn test_send_template_notification() { + let env = Env::default(); + let admin = create_test_address(&env, 1); + let recipient = create_test_address(&env, 2); + + // Initialize system + NotificationManager::initialize(&env).unwrap(); + + // Set up user settings + let settings = UserNotificationSettings { + user: recipient.clone(), + timezone: Bytes::from_slice(&env, b"UTC"), + quiet_hours_start: 22 * 3600, + quiet_hours_end: 8 * 3600, + max_daily_notifications: 50, + do_not_disturb: false, + }; + NotificationManager::update_user_settings(&env, recipient.clone(), settings).unwrap(); + + // Create template + let name = Bytes::from_slice(&env, b"Test Template"); + let mut channels = Vec::new(&env); + channels.push_back(NotificationChannel::Email); + channels.push_back(NotificationChannel::InApp); + + let content = NotificationContent { + subject: Bytes::from_slice(&env, b"Template Subject"), + body: Bytes::from_slice(&env, b"Template body with {{variable}}"), + data: Bytes::new(&env), + localization: Map::new(&env), + }; + + let template_id = NotificationManager::create_template(&env, admin, name, channels, content).unwrap(); + + // Send template notification + let result = NotificationManager::send_template_notification( + &env, + recipient.clone(), + NotificationChannel::Email, + template_id, + Map::new(&env), + ); + assert!(result.is_ok()); + } +} diff --git a/contracts/teachlink/src/score.rs b/contracts/teachlink/src/score.rs index 6aae690..e9aa28c 100644 --- a/contracts/teachlink/src/score.rs +++ b/contracts/teachlink/src/score.rs @@ -1,10 +1,17 @@ -//! Credit score calculation from on-chain activities. -//! -//! Responsibilities: -//! - Award points for course completions and contributions -//! - Maintain per-user score, course list, and contribution history -//! - Emit events on every state change -//! - Expose read-only views for scores and history +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScoreError { + ArithmeticOverflow, + CourseAlreadyCompleted, +} + +pub type ScoreResult = Result; +// Credit score calculation from on-chain activities. +// +// Responsibilities: +// - Award points for course completions and contributions +// - Maintain per-user score, course list, and contribution history +// - Emit events on every state change +// - Expose read-only views for scores and history use crate::errors::{ScoreError, ScoreResult}; use crate::events::{ContributionRecordedEvent, CourseCompletedEvent, CreditScoreUpdatedEvent}; diff --git a/contracts/teachlink/src/storage.rs b/contracts/teachlink/src/storage.rs index fba52da..61cbcf5 100644 --- a/contracts/teachlink/src/storage.rs +++ b/contracts/teachlink/src/storage.rs @@ -228,6 +228,8 @@ pub const BRIDGE_GUARD: Symbol = symbol_short!("br_guard"); pub const REWARDS_GUARD: Symbol = symbol_short!("rw_guard"); pub const SWAP_GUARD: Symbol = symbol_short!("sw_guard"); pub const INSURANCE_GUARD: Symbol = symbol_short!("ins_guard"); +// Tokenization reentrancy guard +pub const TOKENIZATION_GUARD: Symbol = symbol_short!("tok_guard"); // Feature Flags (symbol_short! max 9 chars) pub const FEATURE_FLAGS: Symbol = symbol_short!("feat_flg"); diff --git a/contracts/teachlink/src/tokenization.rs b/contracts/teachlink/src/tokenization.rs index 3d9ff46..3b10dee 100644 --- a/contracts/teachlink/src/tokenization.rs +++ b/contracts/teachlink/src/tokenization.rs @@ -11,6 +11,9 @@ use crate::types::{ContentMetadata, ContentToken, ContentType, TransferType}; pub struct ContentTokenization; +use crate::reentrancy; +use crate::storage::TOKENIZATION_GUARD; + impl ContentTokenization { /// Get the next token ID and increment the counter fn get_next_token_id(env: &Env) -> u64 { @@ -37,77 +40,84 @@ impl ContentTokenization { is_transferable: bool, royalty_percentage: u32, ) -> TokenizationResult { - // Validation Layer - crate::validation::AddressValidator::validate(env, &creator).unwrap(); - - // Metadata validation (if title/description were String, we'd use StringValidator) - // Since they are Bytes, we check length - crate::validation::BytesValidator::validate_length(&title, 1, 100).unwrap(); - crate::validation::BytesValidator::validate_length(&description, 1, 1000).unwrap(); - crate::validation::BytesValidator::validate_length(&content_hash, 32, 32).unwrap(); - - if royalty_percentage > 100 { - panic!("Royalty percentage cannot exceed 100"); - } - - // Batch size check for tags to prevent DoS - bulk_limits::check_batch_size_limit(tags.len(), bulk_limits::MAX_CONTENT_TAGS) - .expect("Too many tags"); - - let timestamp = env.ledger().timestamp(); - let token_id = Self::get_next_token_id(env); - - let metadata = ContentMetadata { - title: title.clone(), - description: description.clone(), - content_type: content_type.clone(), - creator: creator.clone(), - content_hash: content_hash.clone(), - license_type: license_type.clone(), - tags: tags.clone(), - created_at: timestamp, - updated_at: timestamp, - }; - - let token = ContentToken { - token_id, - metadata: metadata.clone(), - owner: creator.clone(), - minted_at: timestamp, - is_transferable, - royalty_percentage, - }; - - // Store the token - env.storage() - .persistent() - .set(&(CONTENT_TOKENS, token_id), &token); - - // Store ownership mapping - env.storage() - .persistent() - .set(&(OWNERSHIP, token_id), &creator); - - // Add token to owner's token list - let mut owner_tokens: Vec = env - .storage() - .persistent() - .get(&(OWNER_TOKENS, creator.clone())) - .unwrap_or(Vec::new(&env)); - owner_tokens.push_back(token_id); - env.storage() - .persistent() - .set(&(OWNER_TOKENS, creator.clone()), &owner_tokens); - - // Emit event - ContentMintedEvent { - token_id, - creator: creator.clone(), - metadata, - } - .publish(env); - - Ok(token_id) + reentrancy::with_guard( + env, + &TOKENIZATION_GUARD, + TokenizationError::StorageError, + || { + // Validation Layer + crate::validation::AddressValidator::validate(env, &creator).unwrap(); + + // Metadata validation (if title/description were String, we'd use StringValidator) + // Since they are Bytes, we check length + crate::validation::BytesValidator::validate_length(&title, 1, 100).unwrap(); + crate::validation::BytesValidator::validate_length(&description, 1, 1000).unwrap(); + crate::validation::BytesValidator::validate_length(&content_hash, 32, 32).unwrap(); + + if royalty_percentage > 100 { + panic!("Royalty percentage cannot exceed 100"); + } + + // Batch size check for tags to prevent DoS + bulk_limits::check_batch_size_limit(tags.len(), bulk_limits::MAX_CONTENT_TAGS) + .expect("Too many tags"); + + let timestamp = env.ledger().timestamp(); + let token_id = Self::get_next_token_id(env); + + let metadata = ContentMetadata { + title: title.clone(), + description: description.clone(), + content_type: content_type.clone(), + creator: creator.clone(), + content_hash: content_hash.clone(), + license_type: license_type.clone(), + tags: tags.clone(), + created_at: timestamp, + updated_at: timestamp, + }; + + let token = ContentToken { + token_id, + metadata: metadata.clone(), + owner: creator.clone(), + minted_at: timestamp, + is_transferable, + royalty_percentage, + }; + + // Store the token + env.storage() + .persistent() + .set(&(CONTENT_TOKENS, token_id), &token); + + // Store ownership mapping + env.storage() + .persistent() + .set(&(OWNERSHIP, token_id), &creator); + + // Add token to owner's token list + let mut owner_tokens: Vec = env + .storage() + .persistent() + .get(&(OWNER_TOKENS, creator.clone())) + .unwrap_or(Vec::new(&env)); + owner_tokens.push_back(token_id); + env.storage() + .persistent() + .set(&(OWNER_TOKENS, creator.clone()), &owner_tokens); + + // Emit event + ContentMintedEvent { + token_id, + creator: creator.clone(), + metadata, + } + .publish(env); + + Ok(token_id) + }, + ) } /// Transfer ownership of a content token @@ -118,82 +128,89 @@ impl ContentTokenization { token_id: u64, notes: Option, ) -> TokenizationResult<()> { - // Get the token - let token: ContentToken = env - .storage() - .persistent() - .get(&(CONTENT_TOKENS, token_id)) - .ok_or(TokenizationError::TokenNotFound)?; - - // Verify ownership - if token.owner != from { - return Err(TokenizationError::UnauthorizedMint); // Using UnauthorizedMint as closest match - } - - // Check if transferable - if !token.is_transferable { - return Err(TokenizationError::InvalidMetadata); // Using InvalidMetadata as closest match - } - - // Update ownership - env.storage().persistent().set(&(OWNERSHIP, token_id), &to); - - // Update token owner - let mut updated_token = token.clone(); - updated_token.owner = to.clone(); - updated_token.metadata.updated_at = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&(CONTENT_TOKENS, token_id), &updated_token); - - // Remove from old owner's list - let from_tokens: Vec = env - .storage() - .persistent() - .get(&(OWNER_TOKENS, from.clone())) - .unwrap_or(Vec::new(env)); - let mut new_from_tokens = Vec::new(env); - for id in from_tokens.iter() { - if id != token_id { - new_from_tokens.push_back(id); - } - } - env.storage() - .persistent() - .set(&(OWNER_TOKENS, from.clone()), &new_from_tokens); - - // Add to new owner's list - let mut to_tokens: Vec = env - .storage() - .persistent() - .get(&(OWNER_TOKENS, to.clone())) - .unwrap_or(Vec::new(env)); - to_tokens.push_back(token_id); - env.storage() - .persistent() - .set(&(OWNER_TOKENS, to.clone()), &to_tokens); - - // Emit event - OwnershipTransferredEvent { - token_id, - from: from.clone(), - to: to.clone(), - timestamp: env.ledger().timestamp(), - } - .publish(env); - - // Record provenance (handled by provenance module) - crate::provenance::ProvenanceTracker::record_transfer( + reentrancy::with_guard( env, - token_id, - Some(from.clone()), - to.clone(), - crate::types::TransferType::Transfer, - notes, + &TOKENIZATION_GUARD, + TokenizationError::StorageError, + || { + // Get the token + let token: ContentToken = env + .storage() + .persistent() + .get(&(CONTENT_TOKENS, token_id)) + .ok_or(TokenizationError::TokenNotFound)?; + + // Verify ownership + if token.owner != from { + return Err(TokenizationError::UnauthorizedMint); // Using UnauthorizedMint as closest match + } + + // Check if transferable + if !token.is_transferable { + return Err(TokenizationError::InvalidMetadata); // Using InvalidMetadata as closest match + } + + // Update ownership + env.storage().persistent().set(&(OWNERSHIP, token_id), &to); + + // Update token owner + let mut updated_token = token.clone(); + updated_token.owner = to.clone(); + updated_token.metadata.updated_at = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&(CONTENT_TOKENS, token_id), &updated_token); + + // Remove from old owner's list + let from_tokens: Vec = env + .storage() + .persistent() + .get(&(OWNER_TOKENS, from.clone())) + .unwrap_or(Vec::new(env)); + let mut new_from_tokens = Vec::new(env); + for id in from_tokens.iter() { + if id != token_id { + new_from_tokens.push_back(id); + } + } + env.storage() + .persistent() + .set(&(OWNER_TOKENS, from.clone()), &new_from_tokens); + + // Add to new owner's list + let mut to_tokens: Vec = env + .storage() + .persistent() + .get(&(OWNER_TOKENS, to.clone())) + .unwrap_or(Vec::new(env)); + to_tokens.push_back(token_id); + env.storage() + .persistent() + .set(&(OWNER_TOKENS, to.clone()), &to_tokens); + + // Emit event + OwnershipTransferredEvent { + token_id, + from: from.clone(), + to: to.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); + + // Record provenance (handled by provenance module) + crate::provenance::ProvenanceTracker::record_transfer( + env, + token_id, + Some(from.clone()), + to.clone(), + crate::types::TransferType::Transfer, + notes, + ) + .map_err(|_| TokenizationError::StorageError)?; // Assuming provenance returns Result + + Ok(()) + }, ) - .map_err(|_| TokenizationError::StorageError)?; // Assuming provenance returns Result - - Ok(()) } /// Get a content token by ID @@ -258,46 +275,53 @@ impl ContentTokenization { description: Option, tags: Option>, ) -> TokenizationResult<()> { - let mut token: ContentToken = env - .storage() - .persistent() - .get(&(CONTENT_TOKENS, token_id)) - .ok_or(TokenizationError::TokenNotFound)?; - - if token.owner != owner { - return Err(TokenizationError::UnauthorizedMint); // Using as closest match - } - - if let Some(new_title) = title { - token.metadata.title = new_title; - } - - if let Some(new_description) = description { - token.metadata.description = new_description; - } - - if let Some(new_tags) = tags { - // Batch size check for tags to prevent DoS - bulk_limits::check_batch_size_limit(new_tags.len(), bulk_limits::MAX_CONTENT_TAGS) - .expect("Too many tags"); - token.metadata.tags = new_tags; - } - - token.metadata.updated_at = env.ledger().timestamp(); - - env.storage() - .persistent() - .set(&(CONTENT_TOKENS, token_id), &token); - - // Emit event - MetadataUpdatedEvent { - token_id, - owner: owner.clone(), - timestamp: env.ledger().timestamp(), - } - .publish(env); - - Ok(()) + reentrancy::with_guard( + env, + &TOKENIZATION_GUARD, + TokenizationError::StorageError, + || { + let mut token: ContentToken = env + .storage() + .persistent() + .get(&(CONTENT_TOKENS, token_id)) + .ok_or(TokenizationError::TokenNotFound)?; + + if token.owner != owner { + return Err(TokenizationError::UnauthorizedMint); // Using as closest match + } + + if let Some(new_title) = title { + token.metadata.title = new_title; + } + + if let Some(new_description) = description { + token.metadata.description = new_description; + } + + if let Some(new_tags) = tags { + // Batch size check for tags to prevent DoS + bulk_limits::check_batch_size_limit(new_tags.len(), bulk_limits::MAX_CONTENT_TAGS) + .expect("Too many tags"); + token.metadata.tags = new_tags; + } + + token.metadata.updated_at = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&(CONTENT_TOKENS, token_id), &token); + + // Emit event + MetadataUpdatedEvent { + token_id, + owner: owner.clone(), + timestamp: env.ledger().timestamp(), + } + .publish(env); + + Ok(()) + }, + ) } /// Set transferability of a token (only by owner) @@ -307,32 +331,39 @@ impl ContentTokenization { token_id: u64, transferable: bool, ) -> TokenizationResult<()> { - let mut token: ContentToken = env - .storage() - .persistent() - .get(&(CONTENT_TOKENS, token_id)) - .ok_or(TokenizationError::TokenNotFound)?; - - if token.owner != owner { - return Err(TokenizationError::UnauthorizedMint); // Using as closest match - } - - token.is_transferable = transferable; - token.metadata.updated_at = env.ledger().timestamp(); - - env.storage() - .persistent() - .set(&(CONTENT_TOKENS, token_id), &token); - - // Emit event - TransferabilityUpdatedEvent { - token_id, - owner: owner.clone(), - transferable, - updated_at: env.ledger().timestamp(), - } - .publish(env); - - Ok(()) + reentrancy::with_guard( + env, + &TOKENIZATION_GUARD, + TokenizationError::StorageError, + || { + let mut token: ContentToken = env + .storage() + .persistent() + .get(&(CONTENT_TOKENS, token_id)) + .ok_or(TokenizationError::TokenNotFound)?; + + if token.owner != owner { + return Err(TokenizationError::UnauthorizedMint); // Using as closest match + } + + token.is_transferable = transferable; + token.metadata.updated_at = env.ledger().timestamp(); + + env.storage() + .persistent() + .set(&(CONTENT_TOKENS, token_id), &token); + + // Emit event + TransferabilityUpdatedEvent { + token_id, + owner: owner.clone(), + transferable, + updated_at: env.ledger().timestamp(), + } + .publish(env); + + Ok(()) + }, + ) } } diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index 0a23606..fa59847 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -1757,6 +1757,8 @@ pub struct FeatureFlag { pub kill_switch_enabled: bool, pub created_at: u64, pub updated_at: u64, +} + // ========== Access Logging Types ========== /// The outcome of a single access attempt. diff --git a/contracts/teachlink/src/validation.rs b/contracts/teachlink/src/validation.rs index 3c85065..ded8309 100644 --- a/contracts/teachlink/src/validation.rs +++ b/contracts/teachlink/src/validation.rs @@ -45,6 +45,10 @@ pub mod config { /// Bridge-specific maximum amount (1e18 base units — ~1 billion tokens /// with 9 decimals; prevents single transactions from draining the pool). pub const MAX_BRIDGE_AMOUNT: i128 = 1_000_000_000_000_000_000; // 1e18 + /// Operational timestamp bound for day-to-day checks (90 days). + pub const MAX_OPERATIONAL_TIMEOUT: u64 = 90 * 24 * 60 * 60; + /// Maximum tolerated clock skew between external and ledger time (15 minutes). + pub const MAX_TIME_SKEW: u64 = 15 * 60; } /// Validation errors @@ -338,9 +342,40 @@ impl StringValidator { end -= 1; } - let trimmed = string.clone(); - Self::validate(&trimmed, max_length)?; - Ok(trimmed) + let mut i = start; + while i <= end { + let ch = bytes.get(i).unwrap() as char; + if !ch.is_alphanumeric() + && !ch.is_whitespace() + && !matches!( + ch, + '-' | '_' + | '.' + | ',' + | '!' + | '?' + | '@' + | '#' + | '$' + | '%' + | '&' + | '*' + | '+' + | '=' + | ':' + ) + { + return Err(ValidationError::InvalidCharacters); + } + i += 1; + } + + let trimmed_len = end - start + 1; + if trimmed_len == 0 || trimmed_len > max_length { + return Err(ValidationError::InvalidStringLength); + } + + Ok(string.clone()) } } diff --git a/contracts/teachlink/tests/test_tokenization.rs b/contracts/teachlink/tests/test_tokenization.rs index 3f305ff..8901a4c 100644 --- a/contracts/teachlink/tests/test_tokenization.rs +++ b/contracts/teachlink/tests/test_tokenization.rs @@ -1,3 +1,39 @@ +#[test] +fn test_tokenization_reentrancy_guard_blocks() { + use teachlink_contract::storage::TOKENIZATION_GUARD; + use teachlink_contract::TokenizationError; + + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(TeachLinkBridge, ()); + let creator = Address::from_array(&env, &[1u8; 32]); + let client = TeachLinkBridgeClient::new(&env, &contract_id); + + // Mint a token normally + let params = create_params( + &env, + creator.clone(), + Bytes::from_slice(&env, b"Title"), + Bytes::from_slice(&env, b"Desc"), + ContentType::Course, + Bytes::from_slice(&env, b"QmHash"), + Bytes::from_slice(&env, b"MIT"), + vec![&env], + true, + 0u32, + ); + let token_id = client.mint_content_token(¶ms); + + // Manually activate the reentrancy guard + env.storage().instance().set(&TOKENIZATION_GUARD, &true); + + // Attempt to transfer should fail with StorageError (guard active) + let new_owner = Address::from_array(&env, &[2u8; 32]); + let result = std::panic::catch_unwind(|| { + client.transfer_content_token(&creator, &new_owner, &token_id, &None); + }); + assert!(result.is_err(), "Expected panic due to reentrancy guard"); +} #![cfg(test)] #![allow(clippy::needless_pass_by_value)] #![allow(clippy::unreadable_literal)] @@ -45,7 +81,7 @@ fn test_mint_content_token() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); + let creator = Address::random(&env); // Set ledger timestamp env.ledger().set(LedgerInfo { @@ -119,8 +155,8 @@ fn test_transfer_content_token() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); - let new_owner = Address::generate(&env); + let creator = Address::random(&env); + let new_owner = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo { @@ -197,9 +233,9 @@ fn test_transfer_not_owner() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); - let attacker = Address::generate(&env); - let new_owner = Address::generate(&env); + let creator = Address::random(&env); + let attacker = Address::from_array(&env, &[3u8; 32]); + let new_owner = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo { @@ -245,8 +281,8 @@ fn test_transfer_non_transferable() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); - let new_owner = Address::generate(&env); + let creator = Address::random(&env); + let new_owner = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo { @@ -291,7 +327,7 @@ fn test_get_owner_tokens() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); + let creator = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo { @@ -353,7 +389,7 @@ fn test_update_metadata() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); + let creator = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo { @@ -429,10 +465,10 @@ fn test_verify_provenance_chain() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); + let creator = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); - let owner1 = Address::generate(&env); - let owner2 = Address::generate(&env); + let owner1 = Address::from_array(&env, &[4u8; 32]); + let owner2 = Address::from_array(&env, &[5u8; 32]); env.ledger().set(LedgerInfo { timestamp: 1000, @@ -508,7 +544,7 @@ fn test_get_token_count() { env.mock_all_auths(); let contract_id = env.register(TeachLinkBridge, ()); - let creator = Address::generate(&env); + let creator = Address::random(&env); let client = TeachLinkBridgeClient::new(&env, &contract_id); env.ledger().set(LedgerInfo {