Skip to content
Open
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
87 changes: 87 additions & 0 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,93 @@ pub(crate) fn purge_voting_state(
Ok(())
}

pub(crate) fn propose_emergency_withdrawal(
env: &Env,
admin: Address,
campaign_id: u32,
recipient: Address,
) -> Result<(), Error> {
assert_admin(env, &admin)?;
let campaign = get_campaign_or_error(env, campaign_id)?;
if campaign.funds_withdrawn || campaign.amount_raised == 0 {
return Err(Error::NoFundsToWithdraw);
}
let propose_timestamp = env.ledger().timestamp();
storage::set_emergency_withdrawal_proposal(env, campaign_id, &recipient, propose_timestamp);
env.events().publish(
(
"emergency_withdrawal_proposed",
campaign_id,
recipient,
propose_timestamp,
),
(),
);
Ok(())
}

pub(crate) fn execute_emergency_withdrawal(
env: &Env,
admin: Address,
campaign_id: u32,
) -> Result<(), Error> {
assert_admin(env, &admin)?;
let (recipient, propose_timestamp) =
storage::get_emergency_withdrawal_proposal(env, campaign_id)
.ok_or(Error::EmergencyWithdrawalNotProposed)?;
let elapsed = env
.ledger()
.timestamp()
.checked_sub(propose_timestamp)
.ok_or(Error::EmergencyWithdrawalTimelockNotMet)?;
if elapsed < crate::EMERGENCY_WITHDRAW_TIMELOCK_SECS {
return Err(Error::EmergencyWithdrawalTimelockNotMet);
}
let mut campaign = get_campaign_or_error(env, campaign_id)?;
let amount = campaign.effective_amount_raised;
if amount == 0 {
return Err(Error::NoFundsToWithdraw);
}
let client = crate::lifecycle::token_client(env);
client.transfer(&env.current_contract_address(), &recipient, &amount);
campaign.funds_withdrawn = true;
campaign.is_active = false;
storage::set_campaign(env, campaign_id, &campaign);
storage::decrement_active_campaign_count(env);
let total_raised = storage::get_total_raised_global(env);
storage::set_total_raised_global(
env,
total_raised.checked_sub(amount).ok_or(Error::Overflow)?,
);
storage::remove_emergency_withdrawal_proposal(env, campaign_id);
env.events().publish(
(
"emergency_withdrawal_executed",
campaign_id,
recipient,
amount,
),
(),
);
Ok(())
}

pub(crate) fn set_max_tx_contribution_fn(
env: &Env,
admin: Address,
amount: i128,
) -> Result<(), Error> {
assert_admin(env, &admin)?;
if amount < 0 {
return Err(Error::ValidationFailed);
}
bump_instance_ttl(env);
storage::set_max_tx_contribution(env, amount);
env.events()
.publish(("max_tx_contribution_updated",), amount);
Ok(())
}

pub(crate) fn resume_campaign(env: &Env, campaign_id: u32, caller: Address) -> Result<(), Error> {
caller.require_auth();

Expand Down
21 changes: 16 additions & 5 deletions src/campaigns/create.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use soroban_sdk::Env;
use soroban_sdk::{Bytes, Env};

use crate::errors::Error;
use crate::lifecycle::{calculate_deadline, require_not_paused};

use crate::storage::{
bump_instance_ttl, get_campaign_count, get_category_campaign_bucket,
get_category_campaign_count, get_category_duration_cap, get_creation_disabled,
get_creator_campaign_bucket, get_creator_campaign_count, get_max_campaign_funding_goal,
get_min_campaign_funding_goal, set_campaign, set_campaign_count, set_campaign_creator_index,
set_campaign_start_time, set_category_campaign_bucket, set_category_campaign_count,
set_creator_campaign_bucket, set_creator_campaign_count, set_revenue_pool,
CATEGORY_CAMPAIGNS_BUCKET_SIZE, CREATOR_CAMPAIGNS_BUCKET_SIZE,
get_min_campaign_funding_goal, has_campaign_title, set_campaign, set_campaign_count,
set_campaign_creator_index, set_campaign_start_time, set_campaign_title_index,
set_category_campaign_bucket, set_category_campaign_count, set_creator_campaign_bucket,
set_creator_campaign_count, set_revenue_pool, CATEGORY_CAMPAIGNS_BUCKET_SIZE,
CREATOR_CAMPAIGNS_BUCKET_SIZE,
};
use crate::types::{Campaign, Category, CreateCampaignParams, MaybePendingCreator};

Expand Down Expand Up @@ -79,6 +81,14 @@ pub(crate) fn create_campaign(env: &Env, params: CreateCampaignParams) -> Result
return Err(Error::ValidationFailed);
}

let mut buf = [0u8; 100];
title.copy_into_slice(&mut buf[..title.len() as usize]);
let title_bytes = Bytes::from_slice(env, &buf[..title.len() as usize]);
let title_hash = env.crypto().sha256(&title_bytes);
if has_campaign_title(env, &creator, &title_hash) {
return Err(Error::DuplicateCampaignTitle);
}

bump_instance_ttl(env);
let mut count = get_campaign_count(env);
count += 1;
Expand Down Expand Up @@ -126,6 +136,7 @@ pub(crate) fn create_campaign(env: &Env, params: CreateCampaignParams) -> Result
set_creator_campaign_bucket(env, &creator, bucket_idx, &bucket);
set_creator_campaign_count(env, &creator, creator_count + 1);
set_campaign_creator_index(env, count, &creator);
set_campaign_title_index(env, &creator, &title_hash);

env.events().publish(
("campaign_created", count, creator),
Expand Down
10 changes: 5 additions & 5 deletions src/campaigns/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> {
if campaign.funds_withdrawn {
return Err(Error::FundsAlreadyWithdrawn);
}
if campaign.amount_raised == 0 {
if campaign.effective_amount_raised == 0 {
return Err(Error::NoFundsToWithdraw);
}

Expand All @@ -45,14 +45,14 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> {
.fee_override
.unwrap_or_else(|| get_platform_fee(env));
// Ceiling division: ceil(a / b) = (a + b - 1) / b. Use checked arithmetic so
// a pathological amount_raised yields Error::Overflow rather than a panic (#408).
// a pathological effective_amount_raised yields Error::Overflow rather than a panic (#408).
let fee_amount = campaign
.amount_raised
.effective_amount_raised
.checked_mul(platform_fee as i128)
.and_then(|n| n.checked_add(crate::BPS_CEIL_OFFSET))
.ok_or(Error::Overflow)?
/ crate::BPS_DENOMINATOR as i128;
let total_after_fee = campaign.amount_raised - fee_amount;
let total_after_fee = campaign.effective_amount_raised - fee_amount;

let reserve_bps = get_withdraw_reserve_percentage(env);
let reserve_amount = total_after_fee
Expand Down Expand Up @@ -89,7 +89,7 @@ pub(crate) fn withdraw_funds(env: &Env, campaign_id: u32) -> Result<(), Error> {
set_total_raised_global(
env,
total_raised
.checked_sub(campaign.amount_raised - reserve_amount)
.checked_sub(campaign.effective_amount_raised - reserve_amount)
.ok_or(Error::Overflow)?,
);

Expand Down
6 changes: 6 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@ pub(crate) const SECONDS_PER_DAY: u64 = 86_400;

/// Delay before a proposed token update can be accepted (7 days).
pub(crate) const TOKEN_UPDATE_DELAY_SECS: u64 = 7 * SECONDS_PER_DAY;

/// Timelock before an emergency withdrawal can be executed (7 days).
pub(crate) const EMERGENCY_WITHDRAW_TIMELOCK_SECS: u64 = 7 * SECONDS_PER_DAY;

/// Default per-transaction contribution limit (0 = unlimited).
pub(crate) const DEFAULT_MAX_CONTRIBUTION_PER_TRANSACTION: i128 = 0;
11 changes: 8 additions & 3 deletions src/contributions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ use crate::lifecycle::{
};
use crate::storage::{
bump_instance_ttl, decrement_contributor_count, get_campaign_block_contribution_count,
get_contribution, get_lifetime_contribution, get_personal_cap, get_total_raised_global,
increment_contributor_count, remove_contribution, remove_personal_cap, remove_revenue_claimed,
set_campaign, set_campaign_block_contribution_count, set_contribution,
get_contribution, get_lifetime_contribution, get_max_tx_contribution, get_personal_cap,
get_total_raised_global, increment_contributor_count, remove_contribution, remove_personal_cap,
remove_revenue_claimed, set_campaign, set_campaign_block_contribution_count, set_contribution,
set_lifetime_contribution, set_personal_cap, set_total_raised_global, AdminKey,
};
use crate::types::Campaign;
Expand Down Expand Up @@ -152,6 +152,11 @@ pub(crate) fn contribute(

check_burst_guard(env, campaign_id, &campaign, amount)?;

let max_per_tx = get_max_tx_contribution(env);
if max_per_tx > 0 && amount > max_per_tx {
return Err(Error::ExceedsMaxContributionPerTransaction);
}

bump_instance_ttl(env);
update_contribution_accounting(
env,
Expand Down
12 changes: 12 additions & 0 deletions src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ pub enum Error {
CampaignAlreadyBookmarked = 44,
/// The campaign is not in the wallet's saved/bookmarked list.
CampaignNotBookmarked = 45,
/// Two campaigns from the same creator have the same title.
DuplicateCampaignTitle = 46,
/// The contribution amount exceeds the per-transaction limit.
ExceedsMaxContributionPerTransaction = 47,
/// No emergency withdrawal proposal exists for this campaign.
EmergencyWithdrawalNotProposed = 48,
/// The emergency withdrawal timelock has not yet elapsed.
EmergencyWithdrawalTimelockNotMet = 49,
}

impl Error {
Expand Down Expand Up @@ -148,6 +156,10 @@ impl Error {
Error::InvalidStateTransition => "InvalidStateTransition",
Error::CampaignAlreadyBookmarked => "CampaignAlreadyBookmarked",
Error::CampaignNotBookmarked => "CampaignNotBookmarked",
Error::DuplicateCampaignTitle => "DuplicateCampaignTitle",
Error::ExceedsMaxContributionPerTransaction => "ExceedsMaxContributionPerTransaction",
Error::EmergencyWithdrawalNotProposed => "EmergencyWithdrawalNotProposed",
Error::EmergencyWithdrawalTimelockNotMet => "EmergencyWithdrawalTimelockNotMet",
}
}
}
Expand Down
31 changes: 30 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ mod types;
mod voting;

pub(crate) use constants::{
BPS_CEIL_OFFSET, BPS_DENOMINATOR, SECONDS_PER_DAY, TOKEN_UPDATE_DELAY_SECS,
BPS_CEIL_OFFSET, BPS_DENOMINATOR, DEFAULT_MAX_CONTRIBUTION_PER_TRANSACTION,
EMERGENCY_WITHDRAW_TIMELOCK_SECS, SECONDS_PER_DAY, TOKEN_UPDATE_DELAY_SECS,
};
pub use errors::Error;
use soroban_sdk::{contract, contractimpl, Address, Env, String};
pub(crate) use storage::get_max_tx_contribution;
use storage::*;
pub use storage::{AdminKey, CampaignKey, ContributionKey, RevenueKey, StorageKey, VotingKey};
pub use types::*;
Expand Down Expand Up @@ -327,6 +329,10 @@ impl ProofOfHeart {
admin::set_campaign_fee_override(&env, campaign_id, admin, fee_bps)
}

pub fn set_max_tx_contribution(env: Env, admin: Address, amount: i128) -> Result<(), Error> {
admin::set_max_tx_contribution_fn(&env, admin, amount)
}

pub fn set_category_duration_cap(
env: Env,
admin: Address,
Expand Down Expand Up @@ -440,6 +446,25 @@ impl ProofOfHeart {
admin::initiate_admin_transfer(&env, admin, new_admin)
}

// ── Admin: emergency withdrawal ───────────────────────────────────────────

pub fn propose_emergency_withdrawal(
env: Env,
admin: Address,
campaign_id: u32,
recipient: Address,
) -> Result<(), Error> {
admin::propose_emergency_withdrawal(&env, admin, campaign_id, recipient)
}

pub fn execute_emergency_withdrawal(
env: Env,
admin: Address,
campaign_id: u32,
) -> Result<(), Error> {
admin::execute_emergency_withdrawal(&env, admin, campaign_id)
}

// ── Admin: migrate ────────────────────────────────────────────────────────

pub fn migrate(env: Env, admin: Address, expected_old_version: u32) -> Result<(), Error> {
Expand Down Expand Up @@ -523,6 +548,10 @@ impl ProofOfHeart {
get_platform_fee(&env)
}

pub fn get_max_tx_contribution(env: Env) -> i128 {
get_max_tx_contribution(&env)
}

pub fn get_min_campaign_funding_goal(env: Env) -> i128 {
get_min_campaign_funding_goal(&env, CAMPAIGN_FUNDING_GOAL_MIN)
}
Expand Down
71 changes: 71 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ pub enum AdminKey {
WithdrawReleaseDelayDays,
/// Percentage of funds held in reserve (basis points).
WithdrawReservePercentage,
/// Maximum contribution per single transaction (0 = unlimited).
MaxContributionPerTransaction,
}

/// Keys for campaign records, indexes, and aggregate campaign counters.
Expand Down Expand Up @@ -110,6 +112,11 @@ pub enum CampaignKey {
/// Reverse mapping from campaign ID to its current creator, keyed by campaign ID.
/// Enables O(1) ownership verification without scanning a creator's campaign bucket.
CampaignCreatorIndex(u32),
/// Emergency withdrawal proposal for a campaign, keyed by campaign ID.
EmergencyWithdrawalProposal(u32),
/// Index of title hashes per creator to enforce title uniqueness, keyed by
/// `(creator, sha256(title))`.
CreatorCampaignTitleIndex(Address, soroban_sdk::BytesN<32>),
}

/// Keys for contributor balances, caps, and contribution tracking.
Expand Down Expand Up @@ -1049,3 +1056,67 @@ pub fn set_campaign_creator_index(env: &Env, campaign_id: u32, creator: &Address
pub fn is_campaign_creator(env: &Env, campaign_id: u32, creator: &Address) -> bool {
get_campaign_creator_index(env, campaign_id).is_some_and(|c| &c == creator)
}

// ── Max contribution per transaction ───────────────────────────────────────────

pub fn get_max_tx_contribution(env: &Env) -> i128 {
env.storage()
.instance()
.get(&AdminKey::MaxContributionPerTransaction)
.unwrap_or(crate::DEFAULT_MAX_CONTRIBUTION_PER_TRANSACTION)
}

pub fn set_max_tx_contribution(env: &Env, amount: i128) {
env.storage()
.instance()
.set(&AdminKey::MaxContributionPerTransaction, &amount);
}

// ── Emergency withdrawal proposal ──────────────────────────────────────────────

pub fn get_emergency_withdrawal_proposal(env: &Env, campaign_id: u32) -> Option<(Address, u64)> {
let key = CampaignKey::EmergencyWithdrawalProposal(campaign_id);
env.storage().persistent().get(&key)
}

pub fn set_emergency_withdrawal_proposal(
env: &Env,
campaign_id: u32,
recipient: &Address,
propose_timestamp: u64,
) {
persistent_set!(
env,
CampaignKey::EmergencyWithdrawalProposal(campaign_id),
&(recipient.clone(), propose_timestamp)
);
}

pub fn remove_emergency_withdrawal_proposal(env: &Env, campaign_id: u32) {
env.storage()
.persistent()
.remove(&CampaignKey::EmergencyWithdrawalProposal(campaign_id));
}

// ── Creator campaign title index ───────────────────────────────────────────────

pub fn has_campaign_title(
env: &Env,
creator: &Address,
title_hash: &soroban_sdk::BytesN<32>,
) -> bool {
let key = CampaignKey::CreatorCampaignTitleIndex(creator.clone(), title_hash.clone());
env.storage().persistent().has(&key)
}

pub fn set_campaign_title_index(
env: &Env,
creator: &Address,
title_hash: &soroban_sdk::BytesN<32>,
) {
persistent_set!(
env,
CampaignKey::CreatorCampaignTitleIndex(creator.clone(), title_hash.clone()),
&true
);
}
Loading
Loading