Implemented automatic fund transfer when campaign donation targets are reached. This streamlines the beneficiary experience by eliminating manual claim steps while maintaining security and consistency.
File: contracts/stellar-give/src/lib.rs
Added AutoClaimedEvent struct (lines ~63-69):
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct AutoClaimedEvent {
pub campaign_id: u64,
pub total_raised: i128,
pub beneficiary: Address,
}Purpose: Emitted when a donation automatically triggers fund distribution to the first beneficiary.
File: contracts/stellar-give/src/lib.rs
Added distribute_funds() helper function (lines ~458-508):
fn distribute_funds(
env: &Env,
admin: &Address,
campaign: &Campaign,
amount: i128,
) -> Result<(), ContractError>Features:
- Calculates platform fee (1% via
calculate_platform_fee()) - Transfers fee to platform admin (skipped if fee rounds to zero)
- Distributes net proceeds proportionally to beneficiaries by basis-point shares
- First beneficiary absorbs rounding dust to ensure exact balance:
fee + Σpayouts == amount
Benefits:
- DRY principle: reused by both
donate()andclaim_funds() - Consistent fee calculation and distribution logic
- Reduced code duplication
File: contracts/stellar-give/src/lib.rs
Modified donate() to auto-claim when target is reached (lines ~725-764):
Workflow:
- Accept donation and update
raised_amount - Check if
raised_amount >= target_amount - If target reached:
- Emit
GoalReachedEvent - Call
distribute_funds()to transfer funds to beneficiaries - Set
campaign.status = CampaignStatus::Claimed - Clear
campaign.raised_amount = 0 - Emit
AutoClaimedEventwith first beneficiary address
- Emit
- Emit standard
DonationEvent - Return early exit via reentrancy lock
Key Points:
- Gas-efficient: all operations within single donation transaction
- Reentrancy-protected via existing
enter_lock()/exit_lock()mechanism - Subsequent donations rejected immediately (campaign no longer Active)
- Handles overshoot: if donation exceeds target, entire raised amount is distributed
File: contracts/stellar-give/src/lib.rs
Simplified claim_funds() (lines ~870-883) to use distribute_funds():
- Removed 60 lines of duplicated distribution logic
- Calls
distribute_funds()once with admin and campaign - Maintains backward compatibility
- Same event emission as before
Added 5 comprehensive tests (lines ~3828-3992):
- Verifies donation exactly meeting target auto-claims
- Checks campaign status changes to
Claimed - Confirms beneficiary receives correct net amount
- Critical security test: verifies subsequent donations fail
- Confirms
CampaignNotActiveerror returned - Prevents donation post-claim exploitation
- Verifies
AutoClaimedEventis emitted with correct payload - Checks campaign_id, total_raised, and beneficiary address
- Confirms indexers can track auto-claim triggers
- Tests donation exceeding target
- Confirms entire raised amount distributed (not just target)
- Validates overshoot handling
- Tests complex benefit-sharing scenario (3 beneficiaries, 50/30/20 split)
- Verifies proportional distribution with rounding
- First beneficiary receives remainder to ensure
Σpayouts == net_proceeds
- Single transaction: donation + auto-claim in one atomic operation
- Lock protection: reentrancy detection prevents recursive calls
- Efficient distribution: O(n) complexity where n = number of beneficiaries (typically 1-3)
- Existing Soroban gas limits apply
- Distribution algorithm uses same pattern as manual
claim_funds() - No new storage operations beyond campaign status update
User Donation
↓
[Reentrancy Lock Enter]
↓
Transfer Tokens to Contract
↓
Update raised_amount
↓
Check: raised_amount >= target?
├─ NO → Emit GoalReachedEvent (if new goal hit)
│ Emit DonationEvent
│ Exit Lock → Success
│
└─ YES → Emit GoalReachedEvent
Call distribute_funds()
Set status = Claimed
Clear raised_amount = 0
Emit AutoClaimedEvent ← NEW
Emit DonationEvent
Exit Lock → Success
Future donations to campaign:
↓
Check: status == Active? → NO
↓
Reject: CampaignNotActive
✅ Fully compatible:
claim_funds()behavior unchanged- Existing campaigns continue to work
- Event structure additions don't break indexers
- Campaign status machine unchanged
- Reentrancy: Protected by existing lock mechanism
- Authorization: No new auth checks needed (donate already requires auth)
- Integrity: Fund distribution uses same validated logic as
claim_funds() - Atomicity: All-or-nothing via Soroban transaction semantics
- State consistency: Campaign marked Claimed, raised_amount zeroed, preventing double-claims
// Campaign with 10M stroops target
let campaign_id = client.create_campaign(
&creator,
&vec![(beneficiary, 10_000)],
&String::from_str(&env, "Relief Fund"),
&String::from_str(&env, "https://..."),
&symbol_short!("relief"),
&10_000_000, // target
&deadline,
&token_address,
&None,
);
// Single donation hits target
client.donate(&donor, &campaign_id, &10_000_000, &false, &None);
// Result:
// - Campaign auto-claims
// - Beneficiary receives 9.9M (after 1% fee)
// - Subsequent donations rejected
// - Indexers see AutoClaimedEventNo migration needed. New feature activates on deployment:
- Existing claimed campaigns unaffected
- Existing active campaigns gain auto-claim on next donation hitting target
- No state transformation required