From 6ea17fd6e8f6a181cc1bbb6871665260a735dbd6 Mon Sep 17 00:00:00 2001 From: JayWebtech Date: Sat, 28 Mar 2026 09:07:32 +0100 Subject: [PATCH] fixes --- .github/workflows/contracts-ci.yml | 8 +- contract/contract/Makefile | 6 +- contract/contract/src/base/events.rs | 15 ++ contract/contract/src/base/types.rs | 2 - contract/contract/src/crowdfunding.rs | 86 +++++++++++- .../contract/src/interfaces/crowdfunding.rs | 10 ++ contract/contract/test/buy_ticket_test.rs | 21 +++ .../contract/test/event_payment_flow_test.rs | 130 ++++++++++++++++++ contract/contract/test/mod.rs | 1 + .../contract/test/upgrade_contract_test.rs | 7 +- 10 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 contract/contract/test/event_payment_flow_test.rs diff --git a/.github/workflows/contracts-ci.yml b/.github/workflows/contracts-ci.yml index bf5c2bb..814d893 100644 --- a/.github/workflows/contracts-ci.yml +++ b/.github/workflows/contracts-ci.yml @@ -37,12 +37,8 @@ jobs: - name: Install wasm32v1-none target run: rustup target add wasm32v1-none - - name: Build contract WASM (required for upgrade integration test) - run: cargo build --target wasm32v1-none --release - working-directory: ./contract/contract - - - name: Run contract tests - run: cargo test + - name: Build WASM and run contract tests + run: make test working-directory: ./contract/contract - name: Build contracts diff --git a/contract/contract/Makefile b/contract/contract/Makefile index b971934..7c6ba5a 100644 --- a/contract/contract/Makefile +++ b/contract/contract/Makefile @@ -1,3 +1,5 @@ +CARGO_TARGET_DIR := $(abspath $(CURDIR)/../target) + default: build all: test @@ -6,8 +8,8 @@ test: build cargo test build: - stellar contract build - @ls -l target/wasm32v1-none/release/*.wasm + CARGO_TARGET_DIR=$(CARGO_TARGET_DIR) stellar contract build + @ls -l $(CARGO_TARGET_DIR)/wasm32v1-none/release/*.wasm fmt: cargo fmt --all diff --git a/contract/contract/src/base/events.rs b/contract/contract/src/base/events.rs index 076ce57..0a2d6e2 100644 --- a/contract/contract/src/base/events.rs +++ b/contract/contract/src/base/events.rs @@ -156,6 +156,21 @@ pub fn event_fees_withdrawn(env: &Env, admin: Address, to: Address, amount: i128 env.events().publish(topics, amount); } +pub fn event_proceeds_withdrawn( + env: &Env, + pool_id: u64, + creator: Address, + to: Address, + amount: i128, +) { + let topics = ( + Symbol::new(env, "event_proceeds_withdrawn"), + pool_id, + creator, + ); + env.events().publish(topics, (to, amount)); +} + pub fn address_blacklisted(env: &Env, admin: Address, address: Address) { let topics = (Symbol::new(env, "address_blacklisted"), admin); env.events().publish(topics, address); diff --git a/contract/contract/src/base/types.rs b/contract/contract/src/base/types.rs index fa66a49..6271c01 100644 --- a/contract/contract/src/base/types.rs +++ b/contract/contract/src/base/types.rs @@ -311,8 +311,6 @@ pub enum StorageKey { EventPlatformFees(u64), // Track if someone bought a ticket UserTicket(u64, Address), - // Event details keyed by event id - Event(BytesN<32>), // Per-event metrics (tickets sold, etc.) EventMetrics(BytesN<32>), } diff --git a/contract/contract/src/crowdfunding.rs b/contract/contract/src/crowdfunding.rs index baee53b..cb2d12c 100644 --- a/contract/contract/src/crowdfunding.rs +++ b/contract/contract/src/crowdfunding.rs @@ -2,6 +2,8 @@ use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; use crate::base::errors::SecondCrowdfundingError; +#[cfg(test)] +use crate::base::types::{EventDetails, EventMetrics}; use crate::base::{ errors::CrowdfundingError, events, @@ -10,9 +12,9 @@ use crate::base::{ }, types::{ CampaignDetails, CampaignLifecycleStatus, CampaignMetrics, Contribution, - EmergencyWithdrawal, EventDetails, EventMetrics, MultiSigConfig, PoolConfig, - PoolContribution, PoolMetadata, PoolMetrics, PoolState, StorageKey, MAX_DESCRIPTION_LENGTH, - MAX_HASH_LENGTH, MAX_STRING_LENGTH, MAX_URL_LENGTH, + EmergencyWithdrawal, MultiSigConfig, PoolConfig, PoolContribution, PoolMetadata, + PoolMetrics, PoolState, StorageKey, MAX_DESCRIPTION_LENGTH, MAX_HASH_LENGTH, + MAX_STRING_LENGTH, MAX_URL_LENGTH, }, }; use crate::interfaces::crowdfunding::CrowdfundingTrait; @@ -305,6 +307,16 @@ impl CrowdfundingTrait for CrowdfundingContract { buyer.require_auth(); + let user_ticket_key = StorageKey::UserTicket(pool_id, buyer.clone()); + if env + .storage() + .instance() + .get::(&user_ticket_key) + .unwrap_or(false) + { + return Err(CrowdfundingError::InvalidPoolState); + } + // ── fee split ──────────────────────────────────────────────────────── let fee_bps: u32 = env .storage() @@ -345,6 +357,8 @@ impl CrowdfundingTrait for CrowdfundingContract { &(current_event_fee_treasury + fee_amount), ); + env.storage().instance().set(&user_ticket_key, &true); + events::ticket_sold(&env, pool_id, buyer, price, event_amount, fee_amount); Ok((event_amount, fee_amount)) } @@ -1194,6 +1208,9 @@ impl CrowdfundingTrait for CrowdfundingContract { .instance() .set(&StorageKey::CreationFee, &creation_fee); env.storage().instance().set(&StorageKey::IsPaused, &false); + env.storage() + .instance() + .set(&StorageKey::PlatformFeeBps, &0u32); Ok(()) } @@ -1811,6 +1828,69 @@ impl CrowdfundingTrait for CrowdfundingContract { Ok(()) } + fn withdraw_event_proceeds( + env: Env, + pool_id: u64, + caller: Address, + to: Address, + amount: i128, + ) -> Result<(), CrowdfundingError> { + if Self::is_paused(env.clone()) { + return Err(CrowdfundingError::ContractPaused); + } + caller.require_auth(); + + let pool_key = StorageKey::Pool(pool_id); + let pool: PoolConfig = env + .storage() + .instance() + .get(&pool_key) + .ok_or(CrowdfundingError::PoolNotFound)?; + + let creator_key = StorageKey::PoolCreator(pool_id); + let creator: Address = env + .storage() + .instance() + .get(&creator_key) + .ok_or(CrowdfundingError::Unauthorized)?; + + if caller != creator { + return Err(CrowdfundingError::Unauthorized); + } + + let state_key = StorageKey::PoolState(pool_id); + let state: PoolState = env + .storage() + .instance() + .get(&state_key) + .unwrap_or(PoolState::Active); + if state != PoolState::Completed { + return Err(CrowdfundingError::InvalidPoolState); + } + + if amount <= 0 { + return Err(CrowdfundingError::InvalidAmount); + } + + let event_pool_key = StorageKey::EventPool(pool_id); + let balance: i128 = env.storage().instance().get(&event_pool_key).unwrap_or(0); + if amount > balance { + return Err(CrowdfundingError::InsufficientBalance); + } + + use soroban_sdk::token; + let token_client = token::Client::new(&env, &pool.token_address); + token_client.transfer(&env.current_contract_address(), &to, &amount); + + env.storage() + .instance() + .set(&event_pool_key, &(balance - amount)); + + events::event_proceeds_withdrawn(&env, pool_id, caller, to, amount); + + Ok(()) + } + fn set_emergency_contact(env: Env, contact: Address) -> Result<(), CrowdfundingError> { let admin: Address = env .storage() diff --git a/contract/contract/src/interfaces/crowdfunding.rs b/contract/contract/src/interfaces/crowdfunding.rs index 5d21f8d..7d291a8 100644 --- a/contract/contract/src/interfaces/crowdfunding.rs +++ b/contract/contract/src/interfaces/crowdfunding.rs @@ -182,6 +182,16 @@ pub trait CrowdfundingTrait { amount: i128, ) -> Result<(), CrowdfundingError>; + /// Withdraw ticket proceeds credited to [`StorageKey::EventPool`] for `pool_id`. + /// Only the pool creator may call; the pool must be in [`PoolState::Completed`]. + fn withdraw_event_proceeds( + env: Env, + pool_id: u64, + caller: Address, + to: Address, + amount: i128, + ) -> Result<(), CrowdfundingError>; + fn set_emergency_contact(env: Env, contact: Address) -> Result<(), CrowdfundingError>; fn get_emergency_contact(env: Env) -> Result; diff --git a/contract/contract/test/buy_ticket_test.rs b/contract/contract/test/buy_ticket_test.rs index 85445fc..1183222 100644 --- a/contract/contract/test/buy_ticket_test.rs +++ b/contract/contract/test/buy_ticket_test.rs @@ -372,3 +372,24 @@ fn test_buy_ticket_requires_buyer_auth() { "buyer auth must be recorded" ); } + +#[test] +fn test_buy_ticket_second_purchase_same_buyer_fails() { + let env = Env::default(); + let (client, _, token) = setup(&env); + let pool_id = create_pool(&client, &env, &token); + + let buyer = Address::generate(&env); + let price = 5_000i128; + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&buyer, &(price * 2)); + + client.buy_ticket(&pool_id, &buyer, &token, &price); + + let second = client.try_buy_ticket(&pool_id, &buyer, &token, &price); + assert_eq!( + second, + Err(Ok(CrowdfundingError::InvalidPoolState)), + "one ticket per buyer per pool" + ); +} diff --git a/contract/contract/test/event_payment_flow_test.rs b/contract/contract/test/event_payment_flow_test.rs new file mode 100644 index 0000000..910ce04 --- /dev/null +++ b/contract/contract/test/event_payment_flow_test.rs @@ -0,0 +1,130 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, token, Address, BytesN, Env, String}; + +use crate::{ + base::{ + errors::CrowdfundingError, + types::{EventDetails, PoolConfig, PoolState, StorageKey}, + }, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, + interfaces::second_crowdfunding::SecondCrowdfundingTrait, +}; + +#[test] +fn test_event_payment_flow_create_buy_withdraw() { + let env = Env::default(); + 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); + + // Platform fee bps persisted at init (issue 4) and readable before any set_* call + let stored_bps: u32 = env.as_contract(&contract_id, || { + env.storage() + .instance() + .get(&StorageKey::PlatformFeeBps) + .expect("PlatformFeeBps must be stored at initialize") + }); + assert_eq!(stored_bps, 0); + assert_eq!(client.get_platform_fee_bps(), 0); + + let creator = Address::generate(&env); + let pool_config = PoolConfig { + name: String::from_str(&env, "Integration Gala"), + description: String::from_str(&env, "End-to-end event payment flow"), + target_amount: 500_000, + min_contribution: 0, + is_private: false, + duration: 86_400, + created_at: env.ledger().timestamp(), + token_address: token.clone(), + }; + let pool_id = client.create_pool(&creator, &pool_config); + + let event_id = BytesN::from_array(&env, &[7u8; 32]); + let ticket_price: i128 = 20_000; + env.as_contract(&contract_id, || { + ::create_event( + env.clone(), + event_id.clone(), + String::from_str(&env, "Gala Night"), + creator.clone(), + ticket_price, + 500, + env.ledger().timestamp() + 86_400, + token.clone(), + ) + .expect("create_event"); + }); + + let details: EventDetails = env.as_contract(&contract_id, || { + env.storage() + .instance() + .get(&StorageKey::Event(event_id.clone())) + .expect("event details stored") + }); + assert_eq!(details.ticket_price, ticket_price); + + client.set_platform_fee_bps(&500); + assert_eq!( + env.as_contract(&contract_id, || { + env.storage() + .instance() + .get::(&StorageKey::PlatformFeeBps) + .unwrap() + }), + 500 + ); + + let buyer = Address::generate(&env); + let token_mint = token::StellarAssetClient::new(&env, &token); + token_mint.mint(&buyer, &ticket_price); + + let token_client = token::Client::new(&env, &token); + let (event_amount, fee_amount) = client.buy_ticket(&pool_id, &buyer, &token, &ticket_price); + assert_eq!(fee_amount, 1_000); + assert_eq!(event_amount, 19_000); + assert_eq!(fee_amount + event_amount, ticket_price); + + assert_eq!( + token_client.balance(&client.address), + ticket_price, + "contract custody matches ticket payment" + ); + + let payout = Address::generate(&env); + let try_early = client.try_withdraw_event_proceeds(&pool_id, &creator, &payout, &event_amount); + assert_eq!( + try_early, + Err(Ok(CrowdfundingError::InvalidPoolState)), + "proceeds only after pool is Completed" + ); + + client.update_pool_state(&pool_id, &PoolState::Completed); + + client.withdraw_event_proceeds(&pool_id, &creator, &payout, &event_amount); + assert_eq!(token_client.balance(&payout), event_amount); + + let fee_receiver = Address::generate(&env); + client.withdraw_event_fees(&admin, &fee_receiver, &fee_amount); + assert_eq!(token_client.balance(&fee_receiver), fee_amount); + + assert_eq!(token_client.balance(&client.address), 0); + + let remaining_event_pool: i128 = env.as_contract(&contract_id, || { + env.storage() + .instance() + .get(&StorageKey::EventPool(pool_id)) + .unwrap_or(0) + }); + assert_eq!(remaining_event_pool, 0); +} diff --git a/contract/contract/test/mod.rs b/contract/contract/test/mod.rs index 5985720..5983d18 100644 --- a/contract/contract/test/mod.rs +++ b/contract/contract/test/mod.rs @@ -6,6 +6,7 @@ mod close_private_pool_test; mod create_event_test; mod create_pool; mod crowdfunding_test; +mod event_payment_flow_test; mod get_pool_contributions_paginated_test; mod platform_fee_test; mod pool_remaining_time_test; diff --git a/contract/contract/test/upgrade_contract_test.rs b/contract/contract/test/upgrade_contract_test.rs index f0953c0..32c4f8b 100644 --- a/contract/contract/test/upgrade_contract_test.rs +++ b/contract/contract/test/upgrade_contract_test.rs @@ -5,10 +5,13 @@ use soroban_sdk::{ Address, BytesN, Env, IntoVal, }; -use crate::crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}; +use crate::{ + base::errors::CrowdfundingError, + crowdfunding::{CrowdfundingContract, CrowdfundingContractClient}, +}; // Import the compiled WASM of this same contract to use as the "new" version -// in the upgrade integration test. +// in the upgrade integration test. Path is relative to the package manifest (`contract/contract/`). mod upgraded_contract { soroban_sdk::contractimport!(file = "../target/wasm32v1-none/release/hello_world.wasm"); }