Skip to content
Merged
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
120 changes: 120 additions & 0 deletions nevo_contract/contracts/hello-world/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const UNCLAIMED_FEES: &str = "unclaimed_fees";

// Creation fee key - stores the fee charged when creating a new pool
const CREATION_FEE_KEY: &str = "creation_fee";
const CROWDFUNDING_TOKEN_KEY: &str = "crowdfunding_token";

// Refund deadline constants
// Donors may request a refund only after the pool deadline has passed AND
Expand Down Expand Up @@ -86,6 +87,8 @@ const POOL_STATE_SET: Symbol = symbol_short!("pool_stat");
const ADMIN_SET: Symbol = symbol_short!("admin_set");
// Issue #954: shared constant replacing inline Symbol::new(&env, "creation_fee_updated")
const FEE_UPDATED: Symbol = symbol_short!("fee_upd");
const CROWDFUNDING_TOKEN_SET: Symbol = symbol_short!("tok_set");
const FEE_PAID: Symbol = symbol_short!("fee_paid");

// ─── Typed Error Enum (Issue #955) ───────────────────────────────────────

Expand Down Expand Up @@ -1559,6 +1562,123 @@ impl Contract {

pool.goal
}

/// Create a new donation / sponsorship pool with creation fee deduction.
///
/// If creation fee > 0, fee is transferred from `creator` to the contract and
/// a `creation_fee_paid` event is emitted.
/// If creation fee == 0, creation proceeds without token transfer or balance checks.
pub fn create_pool_with_fee(
env: Env,
creator: Address,
title: String,
description: String,
goal: u128,
application_deadline: u64,
fee_token: Address,
) -> u32 {
creator.require_auth();

let fee = Self::get_creation_fee(env.clone());
if fee > 0 {
let token_client = token::Client::new(&env, &fee_token);
token_client.transfer(&creator, &env.current_contract_address(), &fee);

// Accumulate unclaimed fees
let unclaimed_key = Symbol::new(&env, UNCLAIMED_FEES);
let current_unclaimed: i128 = env
.storage()
.persistent()
.get::<_, i128>(&unclaimed_key)
.unwrap_or(0);
env.storage()
.persistent()
.set(&unclaimed_key, &(current_unclaimed + fee));

// Emit fee payment events
env.events().publish(
(Symbol::new(&env, "creation_fee_paid"), creator.clone()),
fee,
);
env.events().publish((FEE_PAID,), (creator.clone(), fee));
}

Self::create_pool(
env,
creator,
title,
description,
goal,
application_deadline,
)
}

/// Alias for `create_pool_with_fee`.
pub fn create_campaign_with_fee(
env: Env,
creator: Address,
title: String,
description: String,
goal: u128,
application_deadline: u64,
fee_token: Address,
) -> u32 {
Self::create_pool_with_fee(
env,
creator,
title,
description,
goal,
application_deadline,
fee_token,
)
}

/// Configure the global crowdfunding token.
///
/// # Authorization
/// Only the currently configured admin may call this.
///
/// # Panics
/// - `ContractError::AdminNotSet` if no admin has been configured
/// - `ContractError::UnauthorizedAdmin` if `admin` does not match the stored admin
/// - `"InvalidTokenAddress"` if token address is invalid (e.g. contract's own address)
pub fn set_crowdfunding_token(env: Env, admin: Address, token: Address) {
admin.require_auth();

let admin_key = Symbol::new(&env, ADMIN_KEY);
let stored_admin: Address = env
.storage()
.persistent()
.get::<_, Address>(&admin_key)
.unwrap_or_else(|| env.panic_with_error(ContractError::AdminNotSet));
if stored_admin != admin {
env.panic_with_error(ContractError::UnauthorizedAdmin);
}

if token == env.current_contract_address() {
panic!("InvalidTokenAddress");
}

let token_key = Symbol::new(&env, CROWDFUNDING_TOKEN_KEY);
env.storage().persistent().set(&token_key, &token);

// Emit events
env.events().publish((CROWDFUNDING_TOKEN_SET,), token.clone());
env.events().publish(
(Symbol::new(&env, "crowdfunding_token_set"), admin),
token,
);
}

/// Get the currently configured global crowdfunding token.
pub fn get_crowdfunding_token(env: Env) -> Address {
let token_key = Symbol::new(&env, CROWDFUNDING_TOKEN_KEY);
env.storage()
.persistent()
.get::<_, Address>(&token_key)
.expect("Crowdfunding token not set")
}
}

mod test;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#![cfg(test)]
//! Tests for donor count tracking accuracy — issue #1271.
//!
//! Covers:
//! 1. New campaign has 0 donors.
//! 2. First donation increments to 1.
//! 3. Same donor's multiple donations keeps count at 1.
//! 4. Different donors increment count correctly.
//! 5. Nonexistent campaign returns error.

use super::*;
use soroban_sdk::{testutils::Address as _, Address, Env, String};

fn create_campaign(env: &Env, client: &ContractClient) -> u32 {
let creator = Address::generate(env);
client.create_pool(
&creator,
&String::from_str(env, "Donor Tracking Pool"),
&String::from_str(env, "Validating unique donor count metrics"),
&10_000_000_000u128,
&100_000u64,
)
}

/// (1) New campaign has 0 donors.
#[test]
fn test_new_campaign_has_zero_donors() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let pool_id = create_campaign(&env, &client);

assert_eq!(
client.get_donor_count(&pool_id),
0u32,
"New campaign must report 0 donors"
);
}

/// (2) First donation increments to 1.
#[test]
fn test_first_donation_increments_donor_count_to_one() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let pool_id = create_campaign(&env, &client);
let donor = Address::generate(&env);

client.donate(&pool_id, &donor, &100_000_000u128);

assert_eq!(
client.get_donor_count(&pool_id),
1u32,
"First donation must increment donor count to 1"
);
}

/// (3) Same donor's multiple donations keeps count at 1.
#[test]
fn test_same_donor_multiple_donations_keeps_count_at_one() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let pool_id = create_campaign(&env, &client);
let donor = Address::generate(&env);

client.donate(&pool_id, &donor, &100_000_000u128);
assert_eq!(client.get_donor_count(&pool_id), 1u32);

client.donate(&pool_id, &donor, &50_000_000u128);
assert_eq!(
client.get_donor_count(&pool_id),
1u32,
"Second donation from same donor must keep donor count at 1"
);

client.donate(&pool_id, &donor, &25_000_000u128);
assert_eq!(
client.get_donor_count(&pool_id),
1u32,
"Third donation from same donor must keep donor count at 1"
);
}

/// (4) Different donors increment count correctly.
#[test]
fn test_different_donors_increment_count_correctly() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let pool_id = create_campaign(&env, &client);

let donor_1 = Address::generate(&env);
let donor_2 = Address::generate(&env);
let donor_3 = Address::generate(&env);

client.donate(&pool_id, &donor_1, &100_000_000u128);
assert_eq!(client.get_donor_count(&pool_id), 1u32);

client.donate(&pool_id, &donor_2, &200_000_000u128);
assert_eq!(client.get_donor_count(&pool_id), 2u32);

client.donate(&pool_id, &donor_3, &300_000_000u128);
assert_eq!(client.get_donor_count(&pool_id), 3u32);

// Repeat donation from donor_2 does not increment count
client.donate(&pool_id, &donor_2, &50_000_000u128);
assert_eq!(
client.get_donor_count(&pool_id),
3u32,
"Repeat donation must not increase unique donor count"
);
}

/// (5) Nonexistent campaign returns error.
#[test]
#[should_panic(expected = "Error(Contract, #1)")]
fn test_nonexistent_campaign_donor_count_returns_error() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

client.get_donor_count(&9999u32);
}

#[test]
fn test_nonexistent_campaign_donor_count_try_returns_error() {
let env = Env::default();
let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let res = client.try_get_donor_count(&9999u32);
assert_eq!(
res,
Err(Ok(ContractError::PoolNotFound)),
"Nonexistent campaign must return PoolNotFound error"
);
}
Loading