Skip to content
Open

fixes #264

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .github/workflows/contracts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions contract/contract/Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
CARGO_TARGET_DIR := $(abspath $(CURDIR)/../target)

default: build

all: test
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions contract/contract/src/base/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions contract/contract/src/base/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>),
}
Expand Down
86 changes: 83 additions & 3 deletions contract/contract/src/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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::<StorageKey, bool>(&user_ticket_key)
.unwrap_or(false)
{
return Err(CrowdfundingError::InvalidPoolState);
}

// ── fee split ────────────────────────────────────────────────────────
let fee_bps: u32 = env
.storage()
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions contract/contract/src/interfaces/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address, CrowdfundingError>;
Expand Down
21 changes: 21 additions & 0 deletions contract/contract/test/buy_ticket_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
130 changes: 130 additions & 0 deletions contract/contract/test/event_payment_flow_test.rs
Original file line number Diff line number Diff line change
@@ -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, || {
<CrowdfundingContract as SecondCrowdfundingTrait>::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, u32>(&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);
}
1 change: 1 addition & 0 deletions contract/contract/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions contract/contract/test/upgrade_contract_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down