diff --git a/dongle-smartcontract/README.md b/dongle-smartcontract/README.md index b0438c3..70a55b2 100644 --- a/dongle-smartcontract/README.md +++ b/dongle-smartcontract/README.md @@ -65,6 +65,7 @@ make deploy-testnet - **Verification**: Request and approve project verification - **Fee Management**: Configurable fees for operations - **Access Control**: Owner-based permissions +- **Rate Limiting**: Anti-spam protection for write actions with configurable cooldowns - **TTL Management**: Automatic and manual Time-To-Live extension for persistent storage ## TTL (Time To Live) Management @@ -137,6 +138,11 @@ For complete TTL strategy documentation, see [TTL_STRATEGY.md](../TTL_STRATEGY.m - `set_fee_config` - Configure fees - `set_treasury` - Set treasury address +### Rate Limiting + +- `get_review_action_cooldown_remaining` - Check remaining cooldown for review actions +- `get_verification_request_cooldown_remaining` - Check remaining cooldown for verification requests + ## Development ### Using Makefile @@ -171,24 +177,16 @@ cargo clippy cargo clean ``` -## Testing +## Rate Limiting -The contract includes comprehensive tests covering: +The contract implements anti-spam protection through cooldown-based rate limiting: -- Project registration and validation -- Ownership and authorization -- Review submission and updates -- Verification workflow -- Fee management -- Edge cases and error handling +- **Review Actions** (add/update/delete): 60-second cooldown per user +- **Verification Requests**: 300-second (5-minute) cooldown per user -Run tests: -```bash -cargo test -``` +Rate limits are enforced per user and apply across all projects. Users can check their remaining cooldown time using the provided query functions. -Run specific test: -```bash +This prevents abuse while maintaining reasonable usability for legitimate users. cargo test test_register_project_success ``` diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index b8d1c51..4989ad9 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -34,6 +34,12 @@ pub const RATING_MIN: u32 = 1; #[allow(dead_code)] pub const RATING_MAX: u32 = 5; +/// Rate limiting constants (in seconds) +/// Minimum time between review actions (add/update/delete) per user +pub const REVIEW_ACTION_COOLDOWN: u64 = 60; // 1 minute + +/// Minimum time between verification requests per user +pub const VERIFICATION_REQUEST_COOLDOWN: u64 = 300; // 5 minutes // ── TTL (Time To Live) Constants ────────────────────────────────────────── /// TTL for critical contract data (admin list, fee config, treasury). diff --git a/dongle-smartcontract/src/errors.rs b/dongle-smartcontract/src/errors.rs index ae12e6f..5a072bb 100644 --- a/dongle-smartcontract/src/errors.rs +++ b/dongle-smartcontract/src/errors.rs @@ -41,6 +41,8 @@ pub enum ContractError { CannotRemoveLastAdmin = 17, /// Admin not found AdminNotFound = 18, + /// Rate limit exceeded - too many actions in short time + RateLimitExceeded = 19, /// Invalid project name - empty or whitespace only InvalidProjectName = 19, /// Invalid project description - empty or whitespace only diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 183386e..78d6420 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -8,6 +8,7 @@ pub mod events; mod fee_manager; mod project_registry; pub mod rating_calculator; +mod rate_limiter; pub mod review_registry; pub mod storage_keys; pub mod storage_manager; @@ -196,6 +197,16 @@ impl DongleContract { FeeManager::get_fee_config(&env) } + // --- Rate Limiting --- + + pub fn review_cooldown_remaining(env: Env, user: Address) -> u64 { + crate::rate_limiter::RateLimiter::get_review_action_cooldown_remaining(&env, &user) + } + + pub fn verify_cooldown_remaining(env: Env, user: Address) -> u64 { + crate::rate_limiter::RateLimiter::get_verification_request_cooldown_remaining(&env, &user) + } + // --- TTL Management --- /// Extend TTL for a specific project and its related data diff --git a/dongle-smartcontract/src/rate_limiter.rs b/dongle-smartcontract/src/rate_limiter.rs new file mode 100644 index 0000000..39f44e2 --- /dev/null +++ b/dongle-smartcontract/src/rate_limiter.rs @@ -0,0 +1,76 @@ +//! Rate limiting module to prevent spam and abuse of write actions. +//! +//! This module implements cooldown-based rate limiting for user actions. +//! Each user has separate cooldowns for different action types. + +use crate::constants::{REVIEW_ACTION_COOLDOWN, VERIFICATION_REQUEST_COOLDOWN}; +use crate::errors::ContractError; +use crate::storage_keys::StorageKey; +use soroban_sdk::{Address, Env}; + +/// Rate limiter for user actions +pub struct RateLimiter; + +impl RateLimiter { + /// Check and enforce rate limit for review actions (add/update/delete) + pub fn check_review_action_cooldown(env: &Env, user: &Address) -> Result<(), ContractError> { + let last_action_key = StorageKey::UserLastReviewAction(user.clone()); + let now = env.ledger().timestamp(); + + if let Some(last_timestamp) = env.storage().persistent().get::(&last_action_key) { + if now < last_timestamp + REVIEW_ACTION_COOLDOWN { + return Err(ContractError::RateLimitExceeded); + } + } + + // Update the last action timestamp + env.storage().persistent().set(&last_action_key, &now); + Ok(()) + } + + /// Check and enforce rate limit for verification requests + pub fn check_verification_request_cooldown(env: &Env, user: &Address) -> Result<(), ContractError> { + let last_request_key = StorageKey::UserLastVerificationRequest(user.clone()); + let now = env.ledger().timestamp(); + + if let Some(last_timestamp) = env.storage().persistent().get::(&last_request_key) { + if now < last_timestamp + VERIFICATION_REQUEST_COOLDOWN { + return Err(ContractError::RateLimitExceeded); + } + } + + // Update the last request timestamp + env.storage().persistent().set(&last_request_key, &now); + Ok(()) + } + + /// Get the remaining cooldown time for review actions (in seconds) + /// Returns 0 if no cooldown is active + pub fn get_review_action_cooldown_remaining(env: &Env, user: &Address) -> u64 { + let last_action_key = StorageKey::UserLastReviewAction(user.clone()); + let now = env.ledger().timestamp(); + + if let Some(last_timestamp) = env.storage().persistent().get::(&last_action_key) { + let cooldown_end = last_timestamp + REVIEW_ACTION_COOLDOWN; + if now < cooldown_end { + return cooldown_end - now; + } + } + 0 + } + + /// Get the remaining cooldown time for verification requests (in seconds) + /// Returns 0 if no cooldown is active + pub fn get_verification_request_cooldown_remaining(env: &Env, user: &Address) -> u64 { + let last_request_key = StorageKey::UserLastVerificationRequest(user.clone()); + let now = env.ledger().timestamp(); + + if let Some(last_timestamp) = env.storage().persistent().get::(&last_request_key) { + let cooldown_end = last_timestamp + VERIFICATION_REQUEST_COOLDOWN; + if now < cooldown_end { + return cooldown_end - now; + } + } + 0 + } +} \ No newline at end of file diff --git a/dongle-smartcontract/src/review_registry.rs b/dongle-smartcontract/src/review_registry.rs index 8e41b75..f99a048 100644 --- a/dongle-smartcontract/src/review_registry.rs +++ b/dongle-smartcontract/src/review_registry.rs @@ -4,6 +4,7 @@ use crate::constants::{RATING_MAX, RATING_MIN}; use crate::errors::ContractError; use crate::events::publish_review_event; use crate::rating_calculator::RatingCalculator; +use crate::rate_limiter::RateLimiter; use crate::storage_keys::StorageKey; use crate::storage_manager::StorageManager; use crate::types::{ProjectStats, Review, ReviewAction}; @@ -22,6 +23,9 @@ impl ReviewRegistry { // Validation phase reviewer.require_auth(); + // Check rate limit + RateLimiter::check_review_action_cooldown(env, &reviewer)?; + if !(RATING_MIN..=RATING_MAX).contains(&rating) { return Err(ContractError::InvalidRating); } @@ -119,6 +123,9 @@ impl ReviewRegistry { // Validation phase reviewer.require_auth(); + // Check rate limit + RateLimiter::check_review_action_cooldown(env, &reviewer)?; + if !(RATING_MIN..=RATING_MAX).contains(&rating) { return Err(ContractError::InvalidRating); } @@ -192,6 +199,9 @@ impl ReviewRegistry { // Validation phase reviewer.require_auth(); + // Check rate limit + RateLimiter::check_review_action_cooldown(env, &reviewer)?; + let review_key = StorageKey::Review(project_id, reviewer.clone()); let existing: Review = env .storage() diff --git a/dongle-smartcontract/src/storage_keys.rs b/dongle-smartcontract/src/storage_keys.rs index c0a86e2..8e72315 100644 --- a/dongle-smartcontract/src/storage_keys.rs +++ b/dongle-smartcontract/src/storage_keys.rs @@ -38,4 +38,8 @@ pub enum StorageKey { Treasury, /// List of reviewer addresses for a project (by project_id). ProjectReviews(u64), + /// Last timestamp of review action by user (for rate limiting). + UserLastReviewAction(Address), + /// Last timestamp of verification request by user (for rate limiting). + UserLastVerificationRequest(Address), } diff --git a/dongle-smartcontract/src/tests/fixtures.rs b/dongle-smartcontract/src/tests/fixtures.rs index cdfe5a7..8372401 100644 --- a/dongle-smartcontract/src/tests/fixtures.rs +++ b/dongle-smartcontract/src/tests/fixtures.rs @@ -88,3 +88,18 @@ pub fn assert_project_state( assert_eq!(project.owner, *expected_owner); assert_eq!(project.verification_status, expected_status); } + +/// Get a test environment (convenience function). +pub fn get_test_env() -> Env { + Env::default() +} + +/// Get a test admin address. +pub fn get_admin(env: &Env) -> Address { + Address::generate(env) +} + +/// Get a test user address. +pub fn get_user(env: &Env) -> Address { + Address::generate(env) +} diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index f06b887..3302685 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -2,6 +2,7 @@ // Existing test modules mod admin; +mod rate_limiting; mod error_handling_tests; mod registration; mod review; diff --git a/dongle-smartcontract/src/tests/rate_limiting.rs b/dongle-smartcontract/src/tests/rate_limiting.rs new file mode 100644 index 0000000..b9544a4 --- /dev/null +++ b/dongle-smartcontract/src/tests/rate_limiting.rs @@ -0,0 +1,224 @@ +//! Tests for rate limiting functionality + +use crate::tests::fixtures::{setup_contract, generate_test_users}; +use soroban_sdk::{testutils::Ledger, Address, Env, String}; + +#[test] +fn test_review_action_rate_limiting() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let user = Address::generate(&env); + + // Register a project first + let project_params = crate::types::ProjectRegistrationParams { + owner: user.clone(), + name: String::from_str(&env, "Test Project"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id = client.register_project(&project_params); + + // First review should succeed + let result = client.add_review(&project_id, &user, &5u32, &None); + assert!(result.is_ok()); + + // Immediate second review should fail due to rate limit + let result = client.add_review(&project_id, &user, &4u32, &None); + assert_eq!(result, Err(crate::errors::ContractError::RateLimitExceeded)); + + // Check cooldown remaining + let remaining = client.get_review_action_cooldown_remaining(&user); + assert!(remaining > 0 && remaining <= 60); // Should be close to 60 seconds + + // Advance time by 61 seconds + env.ledger().set_timestamp(env.ledger().timestamp() + 61); + + // Now the review should succeed + let result = client.add_review(&project_id, &user, &4u32, &None); + assert!(result.is_ok()); + + // Cooldown should be reset + let remaining = client.get_review_action_cooldown_remaining(&user); + assert_eq!(remaining, 0); +} + +#[test] +fn test_update_review_rate_limiting() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let user = Address::generate(&env); + + // Register a project + let project_params = crate::types::ProjectRegistrationParams { + owner: user.clone(), + name: String::from_str(&env, "Test Project"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id = client.register_project(&project_params); + + // Add initial review + client.add_review(&project_id, &user, &5u32, &None).unwrap(); + + // Advance time past cooldown + env.ledger().set_timestamp(env.ledger().timestamp() + 61); + + // Update should succeed + let result = client.update_review(&project_id, &user, &4u32, &None); + assert!(result.is_ok()); + + // Immediate update should fail + let result = client.update_review(&project_id, &user, &3u32, &None); + assert_eq!(result, Err(crate::errors::ContractError::RateLimitExceeded)); +} + +#[test] +fn test_delete_review_rate_limiting() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let user = Address::generate(&env); + + // Register a project + let project_params = crate::types::ProjectRegistrationParams { + owner: user.clone(), + name: String::from_str(&env, "Test Project"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id = client.register_project(&project_params); + + // Add review + client.add_review(&project_id, &user, &5u32, &None).unwrap(); + + // Advance time past cooldown + env.ledger().set_timestamp(env.ledger().timestamp() + 61); + + // Delete should succeed + let result = client.delete_review(&project_id, &user); + assert!(result.is_ok()); +} + +#[test] +fn test_verification_request_rate_limiting() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let user = Address::generate(&env); + + // Set up fee config + client.set_fee(&admin, &None, &1000u128, &admin).unwrap(); + + // Register a project + let project_params = crate::types::ProjectRegistrationParams { + owner: user.clone(), + name: String::from_str(&env, "Test Project"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id = client.register_project(&project_params); + + // Pay fee for verification + client.pay_fee(&user, &project_id, &None).unwrap(); + + // First verification request should succeed + let result = client.request_verification(&project_id, &user, &String::from_str(&env, "evidence_cid")); + assert!(result.is_ok()); + + // Immediate second request should fail + let result = client.request_verification(&project_id, &user, &String::from_str(&env, "evidence_cid2")); + assert_eq!(result, Err(crate::errors::ContractError::RateLimitExceeded)); + + // Check cooldown remaining + let remaining = client.get_verification_request_cooldown_remaining(&user); + assert!(remaining > 0 && remaining <= 300); // Should be close to 300 seconds + + // Advance time by 301 seconds + env.ledger().set_timestamp(env.ledger().timestamp() + 301); + + // Register another project for second verification request + let project_params2 = crate::types::ProjectRegistrationParams { + owner: user.clone(), + name: String::from_str(&env, "Test Project 2"), + description: String::from_str(&env, "Test Description 2"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id2 = client.register_project(&project_params2); + + // Pay fee for second project + client.pay_fee(&user, &project_id2, &None).unwrap(); + + // Now verification request should succeed + let result = client.request_verification(&project_id2, &user, &String::from_str(&env, "evidence_cid3")); + assert!(result.is_ok()); +} + +#[test] +fn test_different_users_not_affected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin) = setup_contract(&env); + let users = generate_test_users(&env, 2); + let user1 = users.get(0).unwrap(); + let user2 = users.get(1).unwrap(); + + // Register projects for both users + let project_params1 = crate::types::ProjectRegistrationParams { + owner: user1.clone(), + name: String::from_str(&env, "Test Project 1"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_params2 = crate::types::ProjectRegistrationParams { + owner: user2.clone(), + name: String::from_str(&env, "Test Project 2"), + description: String::from_str(&env, "Test Description"), + category: String::from_str(&env, "Test"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let project_id1 = client.register_project(&project_params1); + let project_id2 = client.register_project(&project_params2); + + // Both users can add reviews simultaneously + let result1 = client.add_review(&project_id1, &user1, &5u32, &None); + assert!(result1.is_ok()); + + let result2 = client.add_review(&project_id2, &user2, &5u32, &None); + assert!(result2.is_ok()); + + // user1 is rate limited, but user2 can still act + let result1_limited = client.add_review(&project_id1, &user1, &4u32, &None); + assert_eq!(result1_limited, Err(crate::errors::ContractError::RateLimitExceeded)); + + let result2_ok = client.add_review(&project_id2, &user2, &4u32, &None); + assert!(result2_ok.is_ok()); +} \ No newline at end of file diff --git a/dongle-smartcontract/src/verification_registry.rs b/dongle-smartcontract/src/verification_registry.rs index 2168fb3..cc081e1 100644 --- a/dongle-smartcontract/src/verification_registry.rs +++ b/dongle-smartcontract/src/verification_registry.rs @@ -8,6 +8,7 @@ use crate::events::{ }; use crate::fee_manager::FeeManager; use crate::project_registry::ProjectRegistry; +use crate::rate_limiter::RateLimiter; use crate::storage_keys::StorageKey; use crate::types::{VerificationRecord, VerificationStatus}; use soroban_sdk::{Address, Env, String, Vec}; @@ -142,6 +143,11 @@ impl VerificationRegistry { requester: Address, evidence_cid: String, ) -> Result<(), ContractError> { + requester.require_auth(); + + // Check rate limit + RateLimiter::check_verification_request_cooldown(env, &requester)?; + // 1. Validate project existence and ownership let mut project = ProjectRegistry::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?;