From de3d62f10bc91a884a91255c02434206ce94dc88 Mon Sep 17 00:00:00 2001 From: keljoshX Date: Fri, 24 Apr 2026 20:54:36 +0100 Subject: [PATCH 1/3] feat: add anti-spam rate limiting for write actions --- dongle-smartcontract/README.md | 26 +- dongle-smartcontract/src/constants.rs | 7 + dongle-smartcontract/src/errors.rs | 2 + dongle-smartcontract/src/lib.rs | 11 + dongle-smartcontract/src/rate_limiter.rs | 76 ++++++ dongle-smartcontract/src/review_registry.rs | 10 + dongle-smartcontract/src/storage_keys.rs | 4 + dongle-smartcontract/src/tests/fixtures.rs | 15 ++ dongle-smartcontract/src/tests/mod.rs | 1 + .../src/tests/rate_limiting.rs | 224 ++++++++++++++++++ .../src/verification_registry.rs | 4 + 11 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 dongle-smartcontract/src/rate_limiter.rs create mode 100644 dongle-smartcontract/src/tests/rate_limiting.rs diff --git a/dongle-smartcontract/README.md b/dongle-smartcontract/README.md index b4ad6df..000e885 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 ## Contract Functions @@ -96,6 +97,11 @@ make deploy-testnet - `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 @@ -130,24 +136,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 0abc2a8..9540c81 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -33,3 +33,10 @@ pub const MAX_CID_LEN: usize = 128; 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 diff --git a/dongle-smartcontract/src/errors.rs b/dongle-smartcontract/src/errors.rs index b40b0eb..be6b24c 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, } // Legacy alias to avoid breaking any code that uses `Error` directly diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 87a9f48..cb0d946 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -7,6 +7,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 types; @@ -186,4 +187,14 @@ impl DongleContract { pub fn get_fee_config(env: Env) -> Result { FeeManager::get_fee_config(&env) } + + // --- Rate Limiting --- + + pub fn get_review_action_cooldown_remaining(env: Env, user: Address) -> u64 { + crate::rate_limiter::RateLimiter::get_review_action_cooldown_remaining(&env, &user) + } + + pub fn get_verification_request_cooldown_remaining(env: Env, user: Address) -> u64 { + crate::rate_limiter::RateLimiter::get_verification_request_cooldown_remaining(&env, &user) + } } diff --git a/dongle-smartcontract/src/rate_limiter.rs b/dongle-smartcontract/src/rate_limiter.rs new file mode 100644 index 0000000..4b4aa11 --- /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 0822ab3..ba36314 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::types::{ProjectStats, Review, ReviewAction}; use soroban_sdk::{Address, Env, String, Vec}; @@ -20,6 +21,9 @@ impl ReviewRegistry { ) -> Result<(), ContractError> { reviewer.require_auth(); + // Check rate limit + RateLimiter::check_review_action_cooldown(env, &reviewer)?; + if !(RATING_MIN..=RATING_MAX).contains(&rating) { return Err(ContractError::InvalidRating); } @@ -107,6 +111,9 @@ impl ReviewRegistry { ) -> Result<(), ContractError> { reviewer.require_auth(); + // Check rate limit + RateLimiter::check_review_action_cooldown(env, &reviewer)?; + if !(RATING_MIN..=RATING_MAX).contains(&rating) { return Err(ContractError::InvalidRating); } @@ -170,6 +177,9 @@ impl ReviewRegistry { ) -> Result<(), ContractError> { 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 b468fd0..526054f 100644 --- a/dongle-smartcontract/src/tests/fixtures.rs +++ b/dongle-smartcontract/src/tests/fixtures.rs @@ -97,3 +97,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 d069e02..4d1c75e 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 registration; mod verification; 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 a41e08f..40b3f46 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}; @@ -23,6 +24,9 @@ impl VerificationRegistry { ) -> 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)?; From fc52deec6fcdc21a1ebd8a2ddb33fe0956127643 Mon Sep 17 00:00:00 2001 From: keljoshX Date: Wed, 29 Apr 2026 12:10:32 +0100 Subject: [PATCH 2/3] Fix --- dongle-smartcontract/src/lib.rs | 4 ++-- dongle-smartcontract/src/rate_limiter.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index cb0d946..518feaf 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -190,11 +190,11 @@ impl DongleContract { // --- Rate Limiting --- - pub fn get_review_action_cooldown_remaining(env: Env, user: Address) -> u64 { + pub fn review_cooldown_remaining(env: Env, user: Address) -> u64 { crate::rate_limiter::RateLimiter::get_review_action_cooldown_remaining(&env, &user) } - pub fn get_verification_request_cooldown_remaining(env: Env, user: Address) -> u64 { + pub fn verify_cooldown_remaining(env: Env, user: Address) -> u64 { crate::rate_limiter::RateLimiter::get_verification_request_cooldown_remaining(&env, &user) } } diff --git a/dongle-smartcontract/src/rate_limiter.rs b/dongle-smartcontract/src/rate_limiter.rs index 4b4aa11..39f44e2 100644 --- a/dongle-smartcontract/src/rate_limiter.rs +++ b/dongle-smartcontract/src/rate_limiter.rs @@ -17,7 +17,7 @@ impl RateLimiter { 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 let Some(last_timestamp) = env.storage().persistent().get::(&last_action_key) { if now < last_timestamp + REVIEW_ACTION_COOLDOWN { return Err(ContractError::RateLimitExceeded); } @@ -33,7 +33,7 @@ impl RateLimiter { 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 let Some(last_timestamp) = env.storage().persistent().get::(&last_request_key) { if now < last_timestamp + VERIFICATION_REQUEST_COOLDOWN { return Err(ContractError::RateLimitExceeded); } @@ -50,7 +50,7 @@ impl RateLimiter { 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 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; @@ -65,7 +65,7 @@ impl RateLimiter { 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 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; From 1e749a17049ae9824c57b1caea5e4ab0ee5ea8b0 Mon Sep 17 00:00:00 2001 From: keljoshX Date: Thu, 30 Apr 2026 07:51:15 +0100 Subject: [PATCH 3/3] Fix --- dongle-smartcontract/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 176c9b3..78d6420 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -205,6 +205,8 @@ impl DongleContract { 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