index

Smart Contract Advanced - Crowdfunding

· 9min

Sekarang kita akan membuat contract yang lebih kompleks dan real-world: Crowdfunding Contract! 🎯

🎯 Apa yang Akan Kita Buat?

Contract crowdfunding sederhana dengan fitur:

  • ✅ Campaign owner set goal & deadline
  • ✅ Anyone can donate
  • ✅ Track total donations
  • ✅ Track individual contributions

Use cases:

  • 💡 Startup funding
  • 🏥 Medical crowdfunding
  • 🎓 Scholarship funding
  • 🌱 Project funding

📁 Setup Project

Dari workspace yang sudah ada:

cd my-token-project
stellar contract init . --name crowdfunding

Ini akan create folder baru:

my-token-project/
├── contracts/
│   ├── token/
│   └── crowdfunding/    ← New!
│       ├── src/
│       │   ├── lib.rs
│       │   └── test.rs

📝 Crowdfunding Contract Code

Buka contracts/crowdfunding/src/lib.rs dan copy paste code ini:

#![no_std]
use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Map, Symbol};

// Storage keys untuk contract data
// Kita pakai symbol_short! untuk efisiensi (max 9 karakter)
const CAMPAIGN_GOAL: Symbol = symbol_short!("goal");
const CAMPAIGN_DEADLINE: Symbol = symbol_short!("deadline");
const TOTAL_RAISED: Symbol = symbol_short!("raised");
const DONATIONS: Symbol = symbol_short!("donations");
const CAMPAIGN_OWNER: Symbol = symbol_short!("owner");
const XLM_TOKEN_ADDRESS: Symbol = symbol_short!("xlm_addr");
const IS_ALREADY_INIT: Symbol = symbol_short!("is_init");

// Contract struct
#[contract]
pub struct CrowdfundingContract;

// Contract implementation
// Note: Kita pakai i128 (signed integer) untuk amounts karena:
// - Ini standard Stellar ecosystem (compatible dengan token contracts)
// - Memungkinkan safe error checking (hitung dulu, validate kemudian)
// - Walaupun donations tidak bisa negatif, i128 membantu catch bugs
#[contractimpl]
impl CrowdfundingContract {

    /// Initialize campaign baru dengan goal, deadline, dan XLM token address
    /// Frontend perlu pass: owner address, goal (in stroops), deadline (unix timestamp), xlm_token (address)
    pub fn initialize(
        env: Env,
        owner: Address,    // Address creator campaign
        goal: i128,        // Target amount (stroops: 1 XLM = 10,000,000 stroops)
        deadline: u64,     // Unix timestamp kapan campaign berakhir
        xlm_token: Address, // XLM token contract address (native token di testnet)
    ) {
       // Verify owner adalah yang claim
        owner.require_auth();

        // Store campaign settings ke blockchain
        env.storage().instance().set(&CAMPAIGN_OWNER, &owner);
        env.storage().instance().set(&CAMPAIGN_GOAL, &goal);
        env.storage().instance().set(&CAMPAIGN_DEADLINE, &deadline);
        env.storage().instance().set(&TOTAL_RAISED, &0i128);
        env.storage().instance().set(&XLM_TOKEN_ADDRESS, &xlm_token);

        // Set initialization flag to true
        env.storage().instance().set(&IS_ALREADY_INIT, &true);

        // Initialize empty donations map
        // Map<Address, i128> = tracking siapa donate berapa
        let donations: Map<Address, i128> = Map::new(&env);
        env.storage().instance().set(&DONATIONS, &donations);
    }

    /// Donate ke campaign menggunakan XLM token transfer
    /// Frontend perlu pass: donor address, amount (stroops)
    pub fn donate(env: Env, donor: Address, amount: i128) {
        // Verify donor authorization
        donor.require_auth();

        // Check apakah campaign masih aktif
        let deadline: u64 = env.storage().instance().get(&CAMPAIGN_DEADLINE).unwrap();
        if env.ledger().timestamp() > deadline {
            panic!("Campaign has ended");
        }

        // Validate amount harus positif
        if amount <= 0 {
            panic!("Donation amount must be positive");
        }

        // Get XLM token contract dan contract address
        let xlm_token_address: Address = env.storage().instance().get(&XLM_TOKEN_ADDRESS).unwrap();
        let xlm_token = token::Client::new(&env, &xlm_token_address);
        let contract_address = env.current_contract_address();

        // Transfer XLM dari donor ke contract ini
        xlm_token.transfer(&donor, &contract_address, &amount);

        // Update total raised
        let mut total: i128 = env.storage().instance().get(&TOTAL_RAISED).unwrap();
        total += amount;
        env.storage().instance().set(&TOTAL_RAISED, &total);

        // Track donasi individual donor
        let mut donations: Map<Address, i128> = env.storage().instance().get(&DONATIONS).unwrap();
        let current_donation = donations.get(donor.clone()).unwrap_or(0);
        donations.set(donor, current_donation + amount);
        env.storage().instance().set(&DONATIONS, &donations);
    }

    /// Get total amount yang sudah terkumpul
    /// Frontend bisa call tanpa parameter
    pub fn get_total_raised(env: Env) -> i128 {
        env.storage().instance().get(&TOTAL_RAISED).unwrap_or(0)
    }

    /// Get berapa banyak specific donor sudah donate
    /// Frontend perlu pass: donor address
    pub fn get_donation(env: Env, donor: Address) -> i128 {
        let donations: Map<Address, i128> = env.storage().instance().get(&DONATIONS).unwrap();
        donations.get(donor).unwrap_or(0)
    }

    // Get initialization status - for frontend to check if contract is initialized
    pub fn get_is_already_init(env: Env) -> bool {
        env.storage().instance().get(&IS_ALREADY_INIT).unwrap_or(false)
    }
    // 🎓 EXERCISE IDEAS untuk student:
    // Function-function ini bisa ditambahkan sebagai latihan:
    //
    // 1. get_goal() -> i128
    //    Return campaign goal amount
    //
    // 2. get_deadline() -> u64
    //    Return campaign deadline timestamp
    //
    // 3. is_goal_reached() -> bool
    //    Check apakah total raised >= goal
    //
    // 4. is_ended() -> bool
    //    Check apakah current time > deadline
    //
    // 5. get_progress_percentage() -> i128
    //    Calculate (total_raised * 100) / goal
    //
    // 6. refund() -> Challenge!
    //    Allow donors dapat uang balik kalau goal tidak tercapai
}

#[cfg(test)]
mod test;

🧪 Test Code

Buka contracts/crowdfunding/src/test.rs:

#![cfg(test)]

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

const XLM_CONTRACT_TESTNET: &str = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";

// Test 1: Initialize campaign successfully
#[test]
fn test_initialize_campaign() {
    // Setup test environment
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    // Create mock addresses
    let owner = Address::generate(&env);
    let goal = 900_000_000i128; // 90 XLM goal (90 * 10^7 stroops)
    let deadline = env.ledger().timestamp() + 86400; // 24 jam dari sekarang
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    // Mock authorization untuk owner
    env.mock_all_auths();

    // Verify campaign initialized dengan benar
    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Verify campaign initialized dengan benar
    assert_eq!(client.get_total_raised(), 0);
}

// Test 2: Make a donation
#[test]
fn test_get_donation_no_donation() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let non_donor = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 86400;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    // Initialize campaign
    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Check donation amount for address that never donated
    assert_eq!(client.get_donation(&non_donor), 0);
}

// Test 3: Cannot donate negative or zero amount
#[test]
#[should_panic(expected = "Donation amount must be positive")]
fn test_donate_zero_amount() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let donor = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 86400;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Try to donate 0 - should panic
    client.donate(&donor, &0);
}

// Test 4: Cannot donate negative amount
#[test]
#[should_panic(expected = "Donation amount must be positive")]
fn test_donate_negative_amount() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let donor = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 86400;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Try to donate negative amount - should panic
    client.donate(&donor, &-100_000_000);
}

// Test 5: Campaign deadline validation
#[test]
#[should_panic(expected = "Campaign has ended")]
fn test_donate_after_deadline() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let donor = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 100;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Fast forward time past deadline
    env.ledger().with_mut(|li| {
        li.timestamp = deadline + 1;
    });

    // This should panic - will fail at XLM transfer but deadline check comes first
    client.donate(&donor, &100_000_000);
}

// Test 6: Check initialization status before initialization
#[test]
fn test_is_already_init_before_initialization() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    // Before initialization, should return false
    assert_eq!(client.get_is_already_init(), false);
}

// Test 7: Check initialization status after initialization
#[test]
fn test_is_already_init_after_initialization() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 86400;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    // Before initialization
    assert_eq!(client.get_is_already_init(), false);

    // Initialize the contract
    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // After initialization, should return true
    assert_eq!(client.get_is_already_init(), true);
}

// Test 8: Initialization flag persists after other operations
#[test]
fn test_is_already_init_persists() {
    let env = Env::default();
    let contract_id = env.register(CrowdfundingContract, ());
    let client = CrowdfundingContractClient::new(&env, &contract_id);

    let owner = Address::generate(&env);
    let donor = Address::generate(&env);
    let goal = 900_000_000i128;
    let deadline = env.ledger().timestamp() + 86400;
    let xlm_token_address =
        Address::from_string(&soroban_sdk::String::from_str(&env, XLM_CONTRACT_TESTNET));

    env.mock_all_auths();

    // Initialize the contract
    client.initialize(&owner, &goal, &deadline, &xlm_token_address);

    // Verify it's initialized
    assert_eq!(client.get_is_already_init(), true);

    // Check after querying other functions
    let _ = client.get_total_raised();
    let _ = client.get_donation(&donor);

    // Should still be true
    assert_eq!(client.get_is_already_init(), true);
}


// 🎓 STUDENT EXERCISE:
// Add test untuk function yang akan kalian implement!
// Examples:
// - test_get_goal() return correct goal
// - test_is_goal_reached() when goal met
// - test_is_ended() after deadline passes
// - test_get_deadline() return correct timestamp

🧪 Run Tests

Jalankan semua tests:

cargo test

Expected output:

running 8 tests
test test::test_is_already_init_before_initialization ... ok
test test::test_get_donation_no_donation ... ok
test test::test_is_already_init_after_initialization ... ok
test test::test_initialize_campaign ... ok
test test::test_donate_after_deadline - should panic ... ok
test test::test_donate_negative_amount - should panic ... ok
test test::test_is_already_init_persists ... ok
test test::test_donate_zero_amount - should panic ... ok

test result: ok. 8 passed; 0 failed

🏗️ Build Contract

Build contract ke WASM:

stellar contract build

Check output:

ls target/wasm32v1-none/release/*.wasm

Seharusnya ada crowdfunding.wasm.


🚀 Deploy ke Testnet

Step 1: Deploy Contract

stellar contract deploy \
  --wasm target/wasm32v1-none/release/crowdfunding.wasm \
  --source-account alice \
  --network testnet \
  --alias crowdfunding

Save contract ID yang muncul!

Step 2: Initialize Campaign

⚠️ Penting: XLM Token Address di Testnet

Di Stellar, bahkan native XLM harus diakses via token contract. Untuk testnet, address-nya:

CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC

Sekarang initialize contract dengan XLM token address:

stellar contract invoke \
  --id <CONTRACT_ID> \
  --source-account alice \
  --network testnet \
  -- \
  initialize \
  --owner <ALICE_PUBLIC_KEY> \
  --goal "100000000" \
  --deadline "<YOUR_DEADLINE>" \
  --xlm_token "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"

Notes:

  • Goal: 100000000 = 10 XLM
  • Deadline: Unix timestamp (gunakan unixtimestamp.com untuk convert)

Step 3: Make Donation

stellar contract invoke \
  --id <CONTRACT_ID> \
  --source-account alice \
  --network testnet \
  -- \
  donate \
  --donor <ALICE_PUBLIC_KEY> \
  --amount "10000000"

Ini donate 1 XLM.

Step 4: Check Total Raised

stellar contract invoke \
  --id <CONTRACT_ID> \
  --source-account alice \
  --network testnet \
  -- \
  get_total_raised

Output: 10000000

Step 5: Check Specific Donation

stellar contract invoke \
  --id <CONTRACT_ID> \
  --source-account alice \
  --network testnet \
  -- \
  get_donation \
  --donor <ALICE_PUBLIC_KEY>

Output: 10000000


💡 Penjelasan Code Detail

Storage Keys

const CAMPAIGN_GOAL: Symbol = symbol_short!("goal");
  • symbol_short!() = macro untuk create Symbol yang efficient
  • Max 9 characters untuk performance optimization
  • Digunakan sebagai key di blockchain storage

⚠️ Penting: Instance Storage & TTL

Contract ini pakai env.storage().instance() untuk semua data:

env.storage().instance().set(&CAMPAIGN_GOAL, &goal);

Kenapa Instance Storage?

  • Data tied to contract lifetime
  • Campaign settings (goal, deadline) should exist selama contract exist
  • Medium cost, lebih murah dari Persistent tapi reliable

⏳ TTL (Time To Live): Storage di Soroban BUKAN permanent! Data punya TTL dan bisa expire.

  • Instance Storage = Share TTL dengan contract
  • Kalau TTL habis → data di-archive (hilang!)
  • Solution: Extend TTL sebelum expire
# Extend contract TTL (contoh: 31 hari)
stellar contract extend \
  --id <CONTRACT_ID> \
  --ledgers-to-extend 535680 \
  --source-account alice \
  --network testnet

Alternatif untuk data critical:

// Use Persistent storage untuk balances/ownership
env.storage().persistent().set(&key, &value);

💡 Pro tip: Untuk production, pertimbangkan:

  • Campaign funds → Persistent storage
  • Campaign metadata → Instance storage
  • Cache/temporary → Temporary storage

Map untuk Track Donations

let donations: Map<Address, i128> = Map::new(&env);
  • Map = seperti HashMap di Rust standard library
  • Key = Address donor
  • Value = Amount yang didonasikan
  • Stored on-chain, bisa diakses kapan saja

XLM Token Integration

let xlm_token_address: Address = env.storage().instance().get(&XLM_TOKEN_ADDRESS).unwrap();
let xlm_token = token::Client::new(&env, &xlm_token_address);
xlm_token.transfer(&donor, &contract_address, &amount);

Kenapa perlu XLM token address?

Di Soroban, semua asset (termasuk native XLM) adalah token contracts! Tidak seperti blockchain lain di mana native coin bisa langsung transfer, di Stellar kita harus:

  1. Simpan address token contract XLM di storage saat initialize
  2. Buat token client dari address tersebut
  3. Call transfer() function dari token contract

XLM Token Address di Testnet:

CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC

Benefit design ini:

  • Consistent API untuk semua assets (XLM, USDC, custom tokens)
  • XLM dapat fitur-fitur token contract (allowances, etc)
  • Lebih composable dan predictable

Time Checking

if env.ledger().timestamp() > deadline {
    panic!("Campaign sudah berakhir");
}
  • env.ledger().timestamp() = current time di blockchain
  • Unix timestamp format (seconds since 1970)
  • Comparison sederhana untuk check deadline

Authorization

owner.require_auth();
  • Ensures address yang call function memang punya authority
  • Di production, ini validate signature
  • Di test, kita mock dengan env.mock_all_auths()

🎯 Real-World Considerations

Contract ini masih simplified. Di production, perlu tambahkan:

Security

  • ✅ Re-entrancy protection
  • ✅ Integer overflow checks
  • ✅ Access control yang lebih robust

Features

  • ✅ Refund mechanism kalau goal tidak tercapai
  • ✅ Withdraw mechanism untuk owner
  • ✅ Milestones/Tiers
  • ✅ Campaign description/metadata

Optimization

  • ✅ Gas optimization
  • ✅ Storage efficiency
  • ✅ Batch operations

🎉 Achievement Unlocked

Anda sudah berhasil:

  • ✅ Membuat crowdfunding contract dari scratch
  • ✅ Implement storage dengan Map
  • ✅ Handle time-based logic
  • ✅ Write comprehensive tests
  • ✅ Deploy dan interact dengan contract di Testnet

📊 Contract Comparison

FeatureToken ContractCrowdfunding Contract
ComplexitySimpleMedium
StorageBasicMap + Multiple keys
LogicStraightforwardTime-based, multi-party
Use CaseDigital currencyFundraising
Real-worldProduction-ready? NoMVP? Yes

Next: Challenge section - Extend fitur contract! 🚀