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
65 changes: 61 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ impl ProofOfHeart {

bump_instance_ttl(&env);
set_admin(&env, &admin);
remove_pending_admin(&env);
set_token(&env, &token);
set_initialized(&env);

Expand Down Expand Up @@ -528,6 +529,7 @@ impl ProofOfHeart {

bump_instance_ttl(&env);
set_contribution(&env, campaign_id, &contributor, 0);
remove_revenue_claimed(&env, campaign_id, &contributor);

let total_raised = get_total_raised_global(&env);
set_total_raised_global(&env, total_raised - amount);
Expand Down Expand Up @@ -910,27 +912,82 @@ impl ProofOfHeart {
get_personal_cap(&env, campaign_id, &contributor).unwrap_or(0)
}

/// Transfers admin privileges to a new address.
/// Initiates transfer of admin privileges to a new address.
///
/// # Authorization
/// Requires the current admin to authorize the call.
pub fn update_admin(env: Env, admin: Address, new_admin: Address) -> Result<(), Error> {
pub fn initiate_admin_transfer(
env: Env,
admin: Address,
new_admin: Address,
) -> Result<(), Error> {
admin.require_auth();
Self::require_not_paused(&env)?;

let current_admin = get_admin(&env);
if admin != current_admin {
return Err(Error::NotAuthorized);
}
if new_admin == current_admin {
return Err(Error::InvalidNewOwner);
}

bump_instance_ttl(&env);
set_pending_admin(&env, &new_admin);
env.events()
.publish(("admin_transfer_initiated",), (current_admin, new_admin));

Ok(())
}

/// Accepts a pending admin transfer. Must be called by the pending admin.
pub fn accept_admin_transfer(env: Env) -> Result<(), Error> {
Self::require_not_paused(&env)?;

let pending_admin = get_pending_admin(&env).ok_or(Error::NoTransferPending)?;
pending_admin.require_auth();

bump_instance_ttl(&env);
let old_admin = get_admin(&env);
set_admin(&env, &pending_admin);
remove_pending_admin(&env);
env.events()
.publish(("admin_updated",), (old_admin, pending_admin));

Ok(())
}

/// Cancels a pending admin transfer.
pub fn cancel_admin_transfer(env: Env, admin: Address) -> Result<(), Error> {
admin.require_auth();
Self::require_not_paused(&env)?;

let current_admin = get_admin(&env);
if admin != current_admin {
return Err(Error::NotAuthorized);
}
if get_pending_admin(&env).is_none() {
return Err(Error::NoTransferPending);
}

bump_instance_ttl(&env);
set_admin(&env, &new_admin);
remove_pending_admin(&env);
env.events()
.publish(("admin_updated",), (current_admin, new_admin));
.publish(("admin_transfer_cancelled",), current_admin);

Ok(())
}

/// Backwards-compatible wrapper that initiates two-step admin transfer.
pub fn update_admin(env: Env, admin: Address, new_admin: Address) -> Result<(), Error> {
Self::initiate_admin_transfer(env, admin, new_admin)
}

/// Returns the pending admin address if transfer is in progress.
pub fn get_pending_admin(env: Env) -> Option<Address> {
get_pending_admin(&env)
}

/// Gets the number of recorded approval votes for a campaign.
pub fn get_approve_votes(env: Env, campaign_id: u32) -> u32 {
get_approve_votes(&env, campaign_id)
Expand Down
25 changes: 25 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub fn bump_instance_ttl(env: &Env) {
pub enum DataKey {
/// The global admin address.
Admin,
/// Pending admin during two-step admin transfer.
PendingAdmin,
/// The contract's accepted token address.
Token,
/// Platform fee in basis points (e.g. 300 = 3%).
Expand Down Expand Up @@ -127,6 +129,23 @@ pub fn set_admin(env: &Env, admin: &Address) {
env.storage().instance().set(&DataKey::Admin, admin);
}

/// Returns the pending admin address if an admin transfer is in progress.
pub fn get_pending_admin(env: &Env) -> Option<Address> {
env.storage().instance().get(&DataKey::PendingAdmin)
}

/// Stores the pending admin address for two-step admin transfer.
pub fn set_pending_admin(env: &Env, pending_admin: &Address) {
env.storage()
.instance()
.set(&DataKey::PendingAdmin, pending_admin);
}

/// Clears any pending admin transfer.
pub fn remove_pending_admin(env: &Env) {
env.storage().instance().remove(&DataKey::PendingAdmin);
}

/// Returns the accepted token address. Panics if not yet initialized.
pub fn get_token(env: &Env) -> Address {
env.storage().instance().get(&DataKey::Token).unwrap()
Expand Down Expand Up @@ -217,6 +236,12 @@ pub fn set_revenue_claimed(env: &Env, campaign_id: u32, contributor: &Address, a
.extend_ttl(&key, BUMP_THRESHOLD, BUMP_AMOUNT);
}

/// Removes the revenue claimed record for a contributor in a campaign.
pub fn remove_revenue_claimed(env: &Env, campaign_id: u32, contributor: &Address) {
let key = DataKey::RevenueClaimed(campaign_id, contributor.clone());
env.storage().persistent().remove(&key);
}

/// Returns the creator's total claimed revenue for a campaign, extending TTL if non-zero.
pub fn get_creator_revenue_claimed(env: &Env, campaign_id: u32) -> i128 {
let key = DataKey::CreatorRevenueClaimed(campaign_id);
Expand Down
121 changes: 121 additions & 0 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,7 @@ fn test_campaign_count_cannot_reset_after_deployment() {
// Admin flows that must NOT reset the counter
let new_admin = Address::generate(&env);
client.update_admin(&admin, &new_admin);
client.accept_admin_transfer();
assert_eq!(client.get_campaign_count(), 3);

client.set_voting_params(&new_admin, &5, &7000);
Expand Down Expand Up @@ -2280,6 +2281,37 @@ fn test_deposit_revenue_non_existent_campaign() {
assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotFound);
}

#[test]
fn test_deposit_revenue_repeated_calls_accumulate_and_emit_events() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();

token_admin.mint(&contributor1, &5000);
token_admin.mint(&creator, &10_000);

let campaign_id = client.create_campaign(&CreateCampaignParams {
creator: creator.clone(),
title: String::from_str(&env, "Repeated Deposits"),
description: String::from_str(&env, "Deposit idempotency"),
funding_goal: 1000,
duration_days: 30,
category: Category::EducationalStartup,
has_revenue_sharing: true,
revenue_share_percentage: 2000,
max_contribution_per_user: 0i128,
});
client.verify_campaign(&campaign_id);
client.contribute(&campaign_id, &contributor1, &1000);
client.withdraw_funds(&campaign_id);

let events_before = env.events().all().len();
for _ in 0..10 {
client.deposit_revenue(&campaign_id, &100);
}
let events_after = env.events().all().len();
assert_eq!(client.get_revenue_pool(&campaign_id), 1000);
assert_eq!(events_after - events_before, 20);
}

// ── Issue 1: Validate refund state mutation order ────────────────────────────

#[test]
Expand Down Expand Up @@ -2426,6 +2458,38 @@ fn test_claim_refund_expired_campaign() {
client.claim_refund(&campaign_id, &contributor1);
assert_eq!(client.get_contribution(&campaign_id, &contributor1), 0);
assert_eq!(token.balance(&contributor1), 5000);
assert_eq!(client.get_revenue_claimed(&campaign_id, &contributor1), 0);
}

#[test]
fn test_claim_refund_clears_existing_revenue_claimed_key() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();
token_admin.mint(&contributor1, &5000);
token_admin.mint(&creator, &10_000);

let campaign_id = client.create_campaign(&CreateCampaignParams {
creator: creator.clone(),
title: String::from_str(&env, "Refund Cleans Revenue Claim"),
description: String::from_str(&env, "Ensure RevenueClaimed key is removed"),
funding_goal: 5000,
duration_days: 30,
category: Category::EducationalStartup,
has_revenue_sharing: true,
revenue_share_percentage: 2000,
max_contribution_per_user: 0i128,
});
client.verify_campaign(&campaign_id);
client.contribute(&campaign_id, &contributor1, &1000);
client.deposit_revenue(&campaign_id, &1000);
client.claim_revenue(&campaign_id, &contributor1);

let claimed_before_refund = client.get_revenue_claimed(&campaign_id, &contributor1);
assert!(claimed_before_refund > 0);

client.cancel_campaign(&campaign_id);
client.claim_refund(&campaign_id, &contributor1);

assert_eq!(client.get_revenue_claimed(&campaign_id, &contributor1), 0);
}

// ── Issue 3: Fuzz/Integration tests for vote_on_campaign ─────────────────────
Expand Down Expand Up @@ -2599,6 +2663,63 @@ fn test_vote_on_cancelled_campaign_fails() {
assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotActive);
}

#[test]
fn test_vote_on_campaign_past_deadline_fails() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();
token_admin.mint(&contributor1, &1000);

let campaign_id = client.create_campaign(&CreateCampaignParams {
creator: creator.clone(),
title: String::from_str(&env, "Deadline Vote"),
description: String::from_str(&env, "Voting deadline gate"),
funding_goal: 1000,
duration_days: 1,
category: Category::Learner,
has_revenue_sharing: false,
revenue_share_percentage: 0,
max_contribution_per_user: 0i128,
});

let deadline = client.get_campaign(&campaign_id).deadline;
env.ledger().set(soroban_sdk::testutils::LedgerInfo {
timestamp: deadline + 1,
protocol_version: 22,
sequence_number: env.ledger().sequence(),
network_id: [0; 32],
base_reserve: 10,
min_temp_entry_ttl: 10,
min_persistent_entry_ttl: 10,
max_entry_ttl: 10,
});

let res = client.try_vote_on_campaign(&campaign_id, &contributor1, &true);
assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotActive);
}

#[test]
fn test_vote_on_campaign_after_withdraw_fails() {
let (env, _admin, creator, contributor1, _, _token, token_admin, client) = setup_env();
token_admin.mint(&contributor1, &2000);

let campaign_id = client.create_campaign(&CreateCampaignParams {
creator: creator.clone(),
title: String::from_str(&env, "Withdrawn Vote"),
description: String::from_str(&env, "Voting withdrawn gate"),
funding_goal: 1000,
duration_days: 30,
category: Category::Learner,
has_revenue_sharing: false,
revenue_share_percentage: 0,
max_contribution_per_user: 0i128,
});
client.verify_campaign(&campaign_id);
client.contribute(&campaign_id, &contributor1, &1000);
client.withdraw_funds(&campaign_id);

let res = client.try_vote_on_campaign(&campaign_id, &contributor1, &true);
assert_eq!(res.unwrap_err().unwrap(), Error::CampaignNotActive);
}

#[test]
fn test_vote_on_campaign_token_weighted() {
let (env, _admin, creator, contributor1, contributor2, _token, token_admin, client) =
Expand Down
20 changes: 20 additions & 0 deletions src/update_admin_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ fn test_update_admin_success() {

let res = client.try_update_admin(&admin, &new_admin);
assert!(res.is_ok());
assert_eq!(client.get_admin(), admin);
assert_eq!(client.get_pending_admin(), Some(new_admin.clone()));

let accept_res = client.try_accept_admin_transfer();
assert!(accept_res.is_ok());
assert_eq!(client.get_admin(), new_admin);
assert_eq!(client.get_pending_admin(), None);
}

#[test]
Expand All @@ -35,3 +41,17 @@ fn test_update_admin_rejects_non_admin() {
let res = client.try_update_admin(&creator, &new_admin);
assert_eq!(res.unwrap_err().unwrap(), Error::NotAuthorized);
}

#[test]
fn test_cancel_admin_transfer() {
let (env, admin, _creator, client) = setup_env();
let new_admin = Address::generate(&env);

client.update_admin(&admin, &new_admin);
assert_eq!(client.get_pending_admin(), Some(new_admin));

let cancel_res = client.try_cancel_admin_transfer(&admin);
assert!(cancel_res.is_ok());
assert_eq!(client.get_pending_admin(), None);
assert_eq!(client.get_admin(), admin);
}
5 changes: 4 additions & 1 deletion src/voting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,11 @@ pub fn cast_vote(env: &Env, campaign_id: u32, voter: Address, approve: bool) ->
voter.require_auth();

let campaign = get_campaign_or_error(env, campaign_id)?;
require_unverified_campaign(&campaign)?;
require_active_campaign(&campaign)?;
if env.ledger().timestamp() > campaign.deadline {
return Err(Error::CampaignNotActive);
}
require_unverified_campaign(&campaign)?;

let balance = token::Client::new(env, &get_token(env)).balance(&voter);
if balance <= 0 {
Expand Down