From 412cfa3fd7ced2e057296a6b3b025afe0b45058d Mon Sep 17 00:00:00 2001 From: Esla kagbu Date: Fri, 24 Apr 2026 12:29:57 +0100 Subject: [PATCH 1/3] feat: harden fee payment and verification flow --- dongle-smartcontract/src/errors.rs | 4 + dongle-smartcontract/src/fee_manager.rs | 6 +- .../src/tests/verification.rs | 111 ++++++++++++++++++ 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/dongle-smartcontract/src/errors.rs b/dongle-smartcontract/src/errors.rs index b40b0eb..170efae 100644 --- a/dongle-smartcontract/src/errors.rs +++ b/dongle-smartcontract/src/errors.rs @@ -41,6 +41,10 @@ pub enum ContractError { CannotRemoveLastAdmin = 17, /// Admin not found AdminNotFound = 18, + /// Fee already paid for this project in the current verification cycle + FeeAlreadyPaid = 19, + /// Token provided does not match the configured payment token + InvalidToken = 20, } // Legacy alias to avoid breaking any code that uses `Error` directly diff --git a/dongle-smartcontract/src/fee_manager.rs b/dongle-smartcontract/src/fee_manager.rs index f9d3ae7..a8b64e2 100644 --- a/dongle-smartcontract/src/fee_manager.rs +++ b/dongle-smartcontract/src/fee_manager.rs @@ -53,8 +53,12 @@ impl FeeManager { .get(&StorageKey::Treasury) .ok_or(ContractError::TreasuryNotSet)?; + if Self::is_fee_paid(env, project_id) { + return Err(ContractError::FeeAlreadyPaid); + } + if config.token != token { - return Err(ContractError::InvalidProjectData); + return Err(ContractError::InvalidToken); } let amount = config.verification_fee; diff --git a/dongle-smartcontract/src/tests/verification.rs b/dongle-smartcontract/src/tests/verification.rs index 3874cb8..db060cf 100644 --- a/dongle-smartcontract/src/tests/verification.rs +++ b/dongle-smartcontract/src/tests/verification.rs @@ -1,3 +1,4 @@ +use crate::errors::ContractError; use crate::types::{ProjectRegistrationParams, VerificationStatus}; use crate::DongleContract; use crate::DongleContractClient; @@ -104,3 +105,113 @@ fn test_reject_verification() { let project = client.get_project(&project_id).unwrap(); assert_eq!(project.verification_status, VerificationStatus::Rejected); } + +#[test] +fn test_duplicate_payment_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Project Dup"), + description: String::from_str(&env, "Description... Description... Description..."), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + token_client.mint(&owner, &1000); + client.set_fee(&admin, &Some(token_address.clone()), &100, &admin); + + // First payment succeeds + client.pay_fee(&owner, &project_id, &Some(token_address.clone())); + + // Second payment in same cycle must be rejected + let result = client + .try_pay_fee(&owner, &project_id, &Some(token_address.clone())); + assert_eq!(result, Err(Ok(ContractError::FeeAlreadyPaid))); +} + +#[test] +fn test_wrong_token_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Project WrongTok"), + description: String::from_str(&env, "Description... Description... Description..."), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + let token_admin = Address::generate(&env); + let correct_token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + let wrong_token_admin = Address::generate(&env); + let wrong_token = env + .register_stellar_asset_contract_v2(wrong_token_admin) + .address(); + + client.set_fee(&admin, &Some(correct_token.clone()), &100, &admin); + + // Paying with a different token must be rejected + let result = client.try_pay_fee(&owner, &project_id, &Some(wrong_token)); + assert_eq!(result, Err(Ok(ContractError::InvalidToken))); +} + +#[test] +fn test_replay_attack_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, owner) = setup(&env); + + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Project Replay"), + description: String::from_str(&env, "Description... Description... Description..."), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + let project_id = client.register_project(¶ms); + + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + token_client.mint(&owner, &1000); + client.set_fee(&admin, &Some(token_address.clone()), &100, &admin); + + // Pay and request verification (fee is consumed) + client.pay_fee(&owner, &project_id, &Some(token_address.clone())); + client.request_verification( + &project_id, + &owner, + &String::from_str(&env, "ipfs://evidence"), + ); + + // Replaying request_verification without paying again must be rejected + let result = client.try_request_verification( + &project_id, + &owner, + &String::from_str(&env, "ipfs://evidence2"), + ); + assert_eq!(result, Err(Ok(ContractError::InvalidStatusTransition))); +} From cbffc80729fd1a89be83d922bcfe84fa00d5a0df Mon Sep 17 00:00:00 2001 From: Esla kagbu Date: Fri, 24 Apr 2026 12:54:37 +0100 Subject: [PATCH 2/3] fix: formatting and lifetime annotations for CI --- dongle-smartcontract/src/tests/fixtures.rs | 4 ++-- dongle-smartcontract/src/tests/verification.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/dongle-smartcontract/src/tests/fixtures.rs b/dongle-smartcontract/src/tests/fixtures.rs index b468fd0..5e522d3 100644 --- a/dongle-smartcontract/src/tests/fixtures.rs +++ b/dongle-smartcontract/src/tests/fixtures.rs @@ -11,7 +11,7 @@ use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; /// Initialize contract with a default admin and return client + admin address. /// /// This is the most basic setup function used by most tests. -pub fn setup_contract(env: &Env) -> (DongleContractClient, Address) { +pub fn setup_contract(env: &Env) -> (DongleContractClient<'_>, Address) { let contract_id = env.register_contract(None, DongleContract); let client = DongleContractClient::new(env, &contract_id); let admin = Address::generate(env); @@ -33,7 +33,7 @@ pub fn generate_test_users(env: &Env, count: u32) -> Vec
{ /// Setup contract with fee configuration enabled. /// /// Returns (client, admin, treasury) tuple. -pub fn setup_with_fees(env: &Env, fee_amount: u128) -> (DongleContractClient, Address, Address) { +pub fn setup_with_fees(env: &Env, fee_amount: u128) -> (DongleContractClient<'_>, Address, Address) { let (client, admin) = setup_contract(env); let treasury = Address::generate(env); diff --git a/dongle-smartcontract/src/tests/verification.rs b/dongle-smartcontract/src/tests/verification.rs index db060cf..b6f082c 100644 --- a/dongle-smartcontract/src/tests/verification.rs +++ b/dongle-smartcontract/src/tests/verification.rs @@ -135,8 +135,7 @@ fn test_duplicate_payment_rejected() { client.pay_fee(&owner, &project_id, &Some(token_address.clone())); // Second payment in same cycle must be rejected - let result = client - .try_pay_fee(&owner, &project_id, &Some(token_address.clone())); + let result = client.try_pay_fee(&owner, &project_id, &Some(token_address.clone())); assert_eq!(result, Err(Ok(ContractError::FeeAlreadyPaid))); } From 1473facd0dfb4c1928f6a6ac428010f6d99e71e5 Mon Sep 17 00:00:00 2001 From: Esla kagbu Date: Thu, 30 Apr 2026 12:48:02 +0100 Subject: [PATCH 3/3] Update tests fixtures with new content --- dongle-smartcontract/src/tests/fixtures.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/dongle-smartcontract/src/tests/fixtures.rs b/dongle-smartcontract/src/tests/fixtures.rs index 36e8254..aaf3763 100644 --- a/dongle-smartcontract/src/tests/fixtures.rs +++ b/dongle-smartcontract/src/tests/fixtures.rs @@ -14,12 +14,6 @@ pub fn setup_contract(env: &Env) -> (DongleContractClient<'_>, Address) { let contract_id = env.register_contract(None, DongleContract); let client = DongleContractClient::new(env, &contract_id); - let admin = Address::generate(env); - - (client, admin) -} - let client = DongleContractClient::new(env, &contract_id); - let admin = Address::generate(env); client.mock_all_auths().initialize(&admin); @@ -41,17 +35,6 @@ pub fn setup_with_fees( env: &Env, fee_amount: u128, ) -> (DongleContractClient<'_>, Address, Address) { - let contract_id = env.register_contract(None, DongleContract); - let client = DongleContractClient::new(env, &contract_id); - - let admin = Address::generate(env); - let treasury = Address::generate(env); - - // assuming your contract has a function to set fees - client.initialize(&admin, &treasury, &fee_amount); - - (client, admin, treasury) -} let (client, admin) = setup_contract(env); let treasury = Address::generate(env); client @@ -104,4 +87,4 @@ pub fn assert_project_state( assert_eq!(project.name, String::from_str(env, expected_name)); assert_eq!(project.owner, *expected_owner); assert_eq!(project.verification_status, expected_status); -} +} \ No newline at end of file