diff --git a/contract/contract/src/crowdfunding.rs b/contract/contract/src/crowdfunding.rs index 4a78136..31d3ade 100644 --- a/contract/contract/src/crowdfunding.rs +++ b/contract/contract/src/crowdfunding.rs @@ -323,6 +323,9 @@ impl CrowdfundingTrait for CrowdfundingContract { buyer.require_auth(); + // ── reentrancy lock ────────────────────────────────────────────────── + reentrancy_lock_logic(&env, pool_id)?; + // ── fee split ──────────────────────────────────────────────────────── let fee_bps: u32 = env .storage() diff --git a/contract/contract/test/buy_ticket_reentrancy_test.rs b/contract/contract/test/buy_ticket_reentrancy_test.rs new file mode 100644 index 0000000..d5f09b0 --- /dev/null +++ b/contract/contract/test/buy_ticket_reentrancy_test.rs @@ -0,0 +1,108 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, token, Address, Env}; + +use crate::{ + base::{errors::CrowdfundingError, types::PoolConfig, types::StorageKey}, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, +}; + +fn setup(env: &Env) -> (CrowdfundingContractClient<'_>, Address, Address) { + env.mock_all_auths(); + let contract_id = env.register(CrowdfundingContract, ()); + let client = CrowdfundingContractClient::new(env, &contract_id); + + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + client.initialize(&admin, &token, &0); + (client, admin, token) +} + +fn create_pool(client: &CrowdfundingContractClient<'_>, env: &Env, token: &Address) -> u64 { + let creator = Address::generate(env); + let config = PoolConfig { + name: soroban_sdk::String::from_str(env, "Ticket Pool"), + description: soroban_sdk::String::from_str(env, "reentrancy test"), + target_amount: 1_000_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: env.ledger().timestamp(), + token_address: token.clone(), + }; + client.create_pool(&creator, &config) +} + +/// Verify that the reentrancy lock is set during buy_ticket by checking that +/// a concurrent call on the same pool is rejected with Unauthorized while the +/// lock is held, and that the lock is released cleanly after the call so a +/// subsequent call succeeds. +#[test] +fn test_buy_ticket_reentrancy_lock_engaged_and_released() { + let env = Env::default(); + let (client, _, token) = setup(&env); + let pool_id = create_pool(&client, &env, &token); + + // Manually set the reentrancy lock to simulate a concurrent in-flight call + env.as_contract(&client.address, || { + env.storage() + .instance() + .set(&StorageKey::ReentrancyLock(pool_id), &true); + }); + + // buy_ticket must be rejected while the lock is held + let buyer = Address::generate(&env); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&buyer, &1_000); + + let result = client.try_buy_ticket(&pool_id, &buyer, &token, &1_000); + assert_eq!( + result, + Err(Ok(CrowdfundingError::Unauthorized)), + "buy_ticket must be blocked while the reentrancy lock is held" + ); + + // Release the lock (simulates the original call completing) + env.as_contract(&client.address, || { + env.storage() + .instance() + .remove(&StorageKey::ReentrancyLock(pool_id)); + }); + + // After the lock is released, buy_ticket must succeed + let result = client.try_buy_ticket(&pool_id, &buyer, &token, &1_000); + assert_eq!( + result, + Ok(Ok((1_000, 0))), + "buy_ticket must succeed once the reentrancy lock is released" + ); +} + +/// Verify that the lock is released after a successful buy_ticket call so +/// subsequent calls on the same pool are not permanently blocked. +#[test] +fn test_buy_ticket_lock_released_after_success() { + let env = Env::default(); + let (client, _, token) = setup(&env); + let pool_id = create_pool(&client, &env, &token); + + let buyer = Address::generate(&env); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&buyer, &2_000); + + // First call succeeds + assert_eq!( + client.try_buy_ticket(&pool_id, &buyer, &token, &1_000), + Ok(Ok((1_000, 0))) + ); + + // Second call also succeeds — lock was released after the first + assert_eq!( + client.try_buy_ticket(&pool_id, &buyer, &token, &1_000), + Ok(Ok((1_000, 0))) + ); +} diff --git a/contract/contract/test/mod.rs b/contract/contract/test/mod.rs index 112a510..c7739e2 100644 --- a/contract/contract/test/mod.rs +++ b/contract/contract/test/mod.rs @@ -2,6 +2,7 @@ mod all_events_test; // mod blacklist_test; // Features not yet implemented mod batch_claim_test; mod buy_ticket_test; +mod buy_ticket_reentrancy_test; mod close_pool_test; mod close_private_pool_test; mod create_event_test;