Smart Contract Advanced - Crowdfunding
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:
- Simpan address token contract XLM di storage saat initialize
- Buat token client dari address tersebut
- 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
| Feature | Token Contract | Crowdfunding Contract |
|---|---|---|
| Complexity | Simple | Medium |
| Storage | Basic | Map + Multiple keys |
| Logic | Straightforward | Time-based, multi-party |
| Use Case | Digital currency | Fundraising |
| Real-world | Production-ready? No | MVP? Yes |
Next: Challenge section - Extend fitur contract! 🚀