diff --git a/Cargo.toml b/Cargo.toml index 39f0556..4d833c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "contracts/prize_pool", "contracts/seasonal_event", "contracts/hint_marketplace", + "contracts/social_tipping", ] [workspace.dependencies] diff --git a/contracts/social_tipping/Cargo.toml b/contracts/social_tipping/Cargo.toml new file mode 100644 index 0000000..0b51982 --- /dev/null +++ b/contracts/social_tipping/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "social_tipping" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/social_tipping/src/lib.rs b/contracts/social_tipping/src/lib.rs new file mode 100644 index 0000000..6c55bc8 --- /dev/null +++ b/contracts/social_tipping/src/lib.rs @@ -0,0 +1,614 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, token, Address, Env, Vec, + String, +}; + +// ────────────────────────────────────────────────────────── +// ERRORS +// ────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum TippingError { + NotInitialized = 1, + AlreadyInitialized = 2, + InvalidAmount = 3, + InvalidRecipient = 4, + UnauthorizedWithdrawal = 5, + TipLimitExceeded = 6, + CooldownActive = 7, + InsufficientBalance = 8, + InvalidBatchSize = 9, +} + +// ────────────────────────────────────────────────────────── +// DATA STRUCTURES +// ────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TipRecord { + pub from: Address, + pub to: Address, + pub amount: i128, + pub message: String, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TipperStats { + pub total_tipped: i128, + pub tip_count: u32, + pub last_tip_time: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecipientStats { + pub total_received: i128, + pub tip_count: u32, + pub total_from_unique_tippers: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TippingConfig { + pub admin: Address, + pub token: Address, + pub max_tip_per_transaction: i128, + pub max_tips_per_day: u32, + pub cooldown_seconds: u64, + pub max_batch_size: u32, + pub is_initialized: bool, +} + +#[contracttype] +pub enum DataKey { + Config, // TippingConfig + TipperBalance(Address), // Address -> i128 (withdrawable balance) + TipperStats(Address), // TipperStats + RecipientStats(Address), // RecipientStats + TipHistory(Address), // Vec (tip history for recipient) + DailyTipCount(Address, u64), // u32 (tips sent on a given day) + TopTippers, // Vec<(Address, i128)> sorted by amount + TopRecipients, // Vec<(Address, i128)> sorted by amount +} + +// ────────────────────────────────────────────────────────── +// CONTRACT +// ────────────────────────────────────────────────────────── + +#[contract] +pub struct SocialTippingContract; + +#[contractimpl] +impl SocialTippingContract { + /// Initialize the tipping contract with configuration + pub fn initialize( + env: Env, + admin: Address, + token: Address, + max_tip_per_transaction: i128, + max_tips_per_day: u32, + cooldown_seconds: u64, + max_batch_size: u32, + ) -> Result<(), TippingError> { + if env.storage().instance().get::<_, TippingConfig>(&DataKey::Config).is_some() { + return Err(TippingError::AlreadyInitialized); + } + + let config = TippingConfig { + admin: admin.clone(), + token: token.clone(), + max_tip_per_transaction, + max_tips_per_day, + cooldown_seconds, + max_batch_size, + is_initialized: true, + }; + + env.storage().instance().set(&DataKey::Config, &config); + + Ok(()) + } + + /// Send a direct tip to a recipient + pub fn tip( + env: Env, + from: Address, + to: Address, + amount: i128, + ) -> Result<(), TippingError> { + from.require_auth(); + + let config = Self::get_config(&env)?; + + // Validation + if amount <= 0 || amount > config.max_tip_per_transaction { + return Err(TippingError::InvalidAmount); + } + + if from == to { + return Err(TippingError::InvalidRecipient); + } + + // Check cooldown and daily limit + Self::check_tip_limits(&env, &from, &config)?; + + // Transfer tokens + let token_client = token::Client::new(&env, &config.token); + token_client.transfer(&from, &to, &amount); + + // Update stats + Self::update_tipper_stats(&env, &from, amount); + Self::update_recipient_stats(&env, &to, amount); + Self::record_tip(&env, &from, &to, amount, String::from_str(&env, "")); + + Ok(()) + } + + /// Send a tip with a message/note + pub fn tip_with_message( + env: Env, + from: Address, + to: Address, + amount: i128, + message: String, + ) -> Result<(), TippingError> { + from.require_auth(); + + let config = Self::get_config(&env)?; + + // Validation + if amount <= 0 || amount > config.max_tip_per_transaction { + return Err(TippingError::InvalidAmount); + } + + if from == to { + return Err(TippingError::InvalidRecipient); + } + + // Check cooldown and daily limit + Self::check_tip_limits(&env, &from, &config)?; + + // Transfer tokens + let token_client = token::Client::new(&env, &config.token); + token_client.transfer(&from, &to, &amount); + + // Update stats + Self::update_tipper_stats(&env, &from, amount); + Self::update_recipient_stats(&env, &to, amount); + Self::record_tip(&env, &from, &to, amount, message); + + Ok(()) + } + + /// Send tips to multiple recipients (batch tipping) + pub fn batch_tip( + env: Env, + from: Address, + recipients: Vec
, + amounts: Vec, + ) -> Result<(), TippingError> { + from.require_auth(); + + let config = Self::get_config(&env)?; + + // Validation + if recipients.len() != amounts.len() { + return Err(TippingError::InvalidBatchSize); + } + + if recipients.len() as u32 > config.max_batch_size { + return Err(TippingError::InvalidBatchSize); + } + + let token_client = token::Client::new(&env, &config.token); + + // Process each tip + for i in 0..recipients.len() { + let to = recipients.get(i).unwrap(); + let amount = amounts.get(i).unwrap(); + + // Validation + if amount <= 0 || amount > config.max_tip_per_transaction { + return Err(TippingError::InvalidAmount); + } + + if from == to { + return Err(TippingError::InvalidRecipient); + } + + // Transfer tokens + token_client.transfer(&from, &to, &amount); + + // Update stats + Self::update_recipient_stats(&env, &to, amount); + Self::record_tip(&env, &from, &to, amount, String::from_str(&env, "")); + } + + // Update tipper stats once + let mut total_amount: i128 = 0; + for i in 0..amounts.len() { + total_amount += amounts.get(i).unwrap(); + } + Self::update_tipper_stats_batch(&env, &from, total_amount, amounts.len() as u32); + + // Check daily limit after all transfers + Self::check_tip_limits(&env, &from, &config)?; + + Ok(()) + } + + /// Get tip history for a recipient + pub fn get_tip_history( + env: Env, + recipient: Address, + ) -> Result, TippingError> { + Self::get_config(&env)?; + + let history = env + .storage() + .instance() + .get::<_, Vec>(&DataKey::TipHistory(recipient.clone())) + .unwrap_or(Vec::new(&env)); + + Ok(history) + } + + /// Get tip statistics for a tipper + pub fn get_tipper_stats( + env: Env, + tipper: Address, + ) -> Result { + Self::get_config(&env)?; + + let stats = env + .storage() + .instance() + .get::<_, TipperStats>(&DataKey::TipperStats(tipper)) + .unwrap_or(TipperStats { + total_tipped: 0, + tip_count: 0, + last_tip_time: 0, + }); + + Ok(stats) + } + + /// Get tip statistics for a recipient + pub fn get_recipient_stats( + env: Env, + recipient: Address, + ) -> Result { + Self::get_config(&env)?; + + let stats = env + .storage() + .instance() + .get::<_, RecipientStats>(&DataKey::RecipientStats(recipient)) + .unwrap_or(RecipientStats { + total_received: 0, + tip_count: 0, + total_from_unique_tippers: 0, + }); + + Ok(stats) + } + + /// Get top tippers leaderboard + pub fn get_top_tippers( + env: Env, + limit: u32, + ) -> Result, TippingError> { + Self::get_config(&env)?; + + let leaderboard = env + .storage() + .instance() + .get::<_, Vec<(Address, i128)>>(&DataKey::TopTippers) + .unwrap_or(Vec::new(&env)); + + let end = core::cmp::min(limit, leaderboard.len()); + let mut result = Vec::new(&env); + for i in 0..end { + result.push_back(leaderboard.get(i).unwrap()); + } + Ok(result) + } + + /// Get top recipients leaderboard + pub fn get_top_recipients( + env: Env, + limit: u32, + ) -> Result, TippingError> { + Self::get_config(&env)?; + + let leaderboard = env + .storage() + .instance() + .get::<_, Vec<(Address, i128)>>(&DataKey::TopRecipients) + .unwrap_or(Vec::new(&env)); + + let end = core::cmp::min(limit, leaderboard.len()); + let mut result = Vec::new(&env); + for i in 0..end { + result.push_back(leaderboard.get(i).unwrap()); + } + Ok(result) + } + + /// Get configuration + pub fn get_config(env: &Env) -> Result { + env.storage() + .instance() + .get::<_, TippingConfig>(&DataKey::Config) + .ok_or(TippingError::NotInitialized) + } + + /// Get the current timestamp (ledger sequence) + pub fn get_timestamp(env: &Env) -> u64 { + env.ledger().timestamp() + } + + // ────────────────────────────────────────────────────────── + // INTERNAL HELPERS + // ────────────────────────────────────────────────────────── + + fn check_tip_limits(env: &Env, from: &Address, config: &TippingConfig) -> Result<(), TippingError> { + let current_time = Self::get_timestamp(env); + let day_key = current_time / 86400; // Seconds in a day + + let daily_count = env + .storage() + .instance() + .get::<_, u32>(&DataKey::DailyTipCount(from.clone(), day_key)) + .unwrap_or(0); + + if daily_count >= config.max_tips_per_day { + return Err(TippingError::TipLimitExceeded); + } + + let stats = env + .storage() + .instance() + .get::<_, TipperStats>(&DataKey::TipperStats(from.clone())) + .unwrap_or(TipperStats { + total_tipped: 0, + tip_count: 0, + last_tip_time: 0, + }); + + if stats.last_tip_time > 0 { + let time_since_last_tip = current_time.saturating_sub(stats.last_tip_time); + if time_since_last_tip < config.cooldown_seconds { + return Err(TippingError::CooldownActive); + } + } + + Ok(()) + } + + fn update_tipper_stats(env: &Env, tipper: &Address, amount: i128) { + let current_time = Self::get_timestamp(env); + let day_key = current_time / 86400; + + let mut stats = env + .storage() + .instance() + .get::<_, TipperStats>(&DataKey::TipperStats(tipper.clone())) + .unwrap_or(TipperStats { + total_tipped: 0, + tip_count: 0, + last_tip_time: 0, + }); + + stats.total_tipped += amount; + stats.tip_count += 1; + stats.last_tip_time = current_time; + + env.storage() + .instance() + .set(&DataKey::TipperStats(tipper.clone()), &stats); + + // Update daily count + let mut daily_count = env + .storage() + .instance() + .get::<_, u32>(&DataKey::DailyTipCount(tipper.clone(), day_key)) + .unwrap_or(0); + + daily_count += 1; + + env.storage() + .instance() + .set(&DataKey::DailyTipCount(tipper.clone(), day_key), &daily_count); + + // Update top tippers leaderboard + Self::update_top_tippers(env, tipper, stats.total_tipped); + } + + fn update_tipper_stats_batch(env: &Env, tipper: &Address, amount: i128, tip_count: u32) { + let current_time = Self::get_timestamp(env); + let day_key = current_time / 86400; + + let mut stats = env + .storage() + .instance() + .get::<_, TipperStats>(&DataKey::TipperStats(tipper.clone())) + .unwrap_or(TipperStats { + total_tipped: 0, + tip_count: 0, + last_tip_time: 0, + }); + + stats.total_tipped += amount; + stats.tip_count += tip_count; + stats.last_tip_time = current_time; + + env.storage() + .instance() + .set(&DataKey::TipperStats(tipper.clone()), &stats); + + // Update daily count + let mut daily_count = env + .storage() + .instance() + .get::<_, u32>(&DataKey::DailyTipCount(tipper.clone(), day_key)) + .unwrap_or(0); + + daily_count += tip_count; + + env.storage() + .instance() + .set(&DataKey::DailyTipCount(tipper.clone(), day_key), &daily_count); + + // Update top tippers leaderboard + Self::update_top_tippers(env, tipper, stats.total_tipped); + } + + fn update_recipient_stats(env: &Env, recipient: &Address, amount: i128) { + let mut stats = env + .storage() + .instance() + .get::<_, RecipientStats>(&DataKey::RecipientStats(recipient.clone())) + .unwrap_or(RecipientStats { + total_received: 0, + tip_count: 0, + total_from_unique_tippers: 0, + }); + + stats.total_received += amount; + stats.tip_count += 1; + // Note: Tracking unique tippers would require more complex logic + + env.storage() + .instance() + .set(&DataKey::RecipientStats(recipient.clone()), &stats); + + // Update top recipients leaderboard + Self::update_top_recipients(env, recipient, stats.total_received); + } + + fn record_tip( + env: &Env, + from: &Address, + to: &Address, + amount: i128, + message: String, + ) { + let current_time = Self::get_timestamp(env); + + let record = TipRecord { + from: from.clone(), + to: to.clone(), + amount, + message, + timestamp: current_time, + }; + + let mut history = env + .storage() + .instance() + .get::<_, Vec>(&DataKey::TipHistory(to.clone())) + .unwrap_or(Vec::new(env)); + + history.push_back(record); + + env.storage() + .instance() + .set(&DataKey::TipHistory(to.clone()), &history); + } + + fn update_top_tippers(env: &Env, tipper: &Address, total_amount: i128) { + let mut leaderboard = env + .storage() + .instance() + .get::<_, Vec<(Address, i128)>>(&DataKey::TopTippers) + .unwrap_or(Vec::new(env)); + + // Find and update or insert + let mut found = false; + for i in 0..leaderboard.len() { + let (addr, _) = leaderboard.get(i).unwrap(); + if addr == tipper.clone() { + leaderboard.set(i, (tipper.clone(), total_amount)); + found = true; + break; + } + } + + if !found { + leaderboard.push_back((tipper.clone(), total_amount)); + } + + // Sort by amount descending (simple bubble sort for small lists) + for i in 0..leaderboard.len() { + let limit = leaderboard.len().saturating_sub(i as u32).saturating_sub(1); + for j in 0..limit { + let j_plus_1 = j + 1; + let (_, amount_j) = leaderboard.get(j).unwrap(); + let (_, amount_j1) = leaderboard.get(j_plus_1).unwrap(); + if amount_j < amount_j1 { + let temp_j = leaderboard.get(j).unwrap(); + let temp_j1 = leaderboard.get(j_plus_1).unwrap(); + leaderboard.set(j, temp_j1); + leaderboard.set(j_plus_1, temp_j); + } + } + } + + env.storage() + .instance() + .set(&DataKey::TopTippers, &leaderboard); + } + + fn update_top_recipients(env: &Env, recipient: &Address, total_amount: i128) { + let mut leaderboard = env + .storage() + .instance() + .get::<_, Vec<(Address, i128)>>(&DataKey::TopRecipients) + .unwrap_or(Vec::new(env)); + + // Find and update or insert + let mut found = false; + for i in 0..leaderboard.len() { + let (addr, _) = leaderboard.get(i).unwrap(); + if addr == recipient.clone() { + leaderboard.set(i, (recipient.clone(), total_amount)); + found = true; + break; + } + } + + if !found { + leaderboard.push_back((recipient.clone(), total_amount)); + } + + // Sort by amount descending + for i in 0..leaderboard.len() { + let limit = leaderboard.len().saturating_sub(i as u32).saturating_sub(1); + for j in 0..limit { + let j_plus_1 = j + 1; + let (_, amount_j) = leaderboard.get(j).unwrap(); + let (_, amount_j1) = leaderboard.get(j_plus_1).unwrap(); + if amount_j < amount_j1 { + let temp_j = leaderboard.get(j).unwrap(); + let temp_j1 = leaderboard.get(j_plus_1).unwrap(); + leaderboard.set(j, temp_j1); + leaderboard.set(j_plus_1, temp_j); + } + } + } + + env.storage() + .instance() + .set(&DataKey::TopRecipients, &leaderboard); + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/social_tipping/src/test.rs b/contracts/social_tipping/src/test.rs new file mode 100644 index 0000000..7321ae6 --- /dev/null +++ b/contracts/social_tipping/src/test.rs @@ -0,0 +1,547 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, String, +}; + +fn create_token_contract<'a>(e: &'a Env, admin: &Address) -> (Address, TokenClient<'a>) { + let sac = e.register_stellar_asset_contract_v2(admin.clone()); + let address = sac.address(); + (address.clone(), TokenClient::new(e, &address)) +} + +fn create_tipping_contract(e: &Env) -> Address { + e.register_contract(None, SocialTippingContract) +} + +fn setup_contract(e: &Env) -> (Address, Address, Address, TokenClient) { + e.mock_all_auths(); + + let admin = Address::generate(e); + let token_admin = Address::generate(e); + let (token_address, token_client) = create_token_contract(e, &token_admin); + let tipping_contract = create_tipping_contract(e); + + let token_admin_client = StellarAssetClient::new(e, &token_address); + + let client = SocialTippingContractClient::new(e, &tipping_contract); + client.initialize( + &admin, + &token_address, + &1_000_000, // max_tip_per_transaction + &10, // max_tips_per_day + &60, // cooldown_seconds + &5, // max_batch_size + ); + + // Mint tokens to users + token_admin_client.mint(&admin, &100_000_000); + + (tipping_contract, token_address, admin, token_client) +} + +#[test] +fn test_initialize() { + let e = Env::default(); + e.mock_all_auths(); + + let admin = Address::generate(&e); + let token_admin = Address::generate(&e); + let (token_address, _) = create_token_contract(&e, &token_admin); + let tipping_contract = create_tipping_contract(&e); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + client.initialize( + &admin, + &token_address, + &1_000_000, + &10, + &60, + &5, + ); + + let config = client.get_config(); + assert_eq!(config.admin, admin); + assert_eq!(config.token, token_address); + assert_eq!(config.max_tip_per_transaction, 1_000_000); + assert_eq!(config.max_tips_per_day, 10); + assert_eq!(config.cooldown_seconds, 60); + assert_eq!(config.max_batch_size, 5); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] +fn test_initialize_twice_should_fail() { + let e = Env::default(); + e.mock_all_auths(); + + let admin = Address::generate(&e); + let token_admin = Address::generate(&e); + let (token_address, _) = create_token_contract(&e, &token_admin); + let tipping_contract = create_tipping_contract(&e); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + client.initialize(&admin, &token_address, &1_000_000, &10, &60, &5); + client.initialize(&admin, &token_address, &1_000_000, &10, &60, &5); +} + +#[test] +fn test_direct_tip() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send a direct tip + client.tip(&tipper, &recipient, &100_000); + + // Check tipper stats + let tipper_stats = client.get_tipper_stats(&tipper); + assert_eq!(tipper_stats.total_tipped, 100_000); + assert_eq!(tipper_stats.tip_count, 1); + + // Check recipient stats + let recipient_stats = client.get_recipient_stats(&recipient); + assert_eq!(recipient_stats.total_received, 100_000); + assert_eq!(recipient_stats.tip_count, 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_tip_with_invalid_amount() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Try to send zero tip + client.tip(&tipper, &recipient, &0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_tip_to_self_should_fail() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Try to tip self + client.tip(&tipper, &tipper, &100_000); +} + +#[test] +fn test_tip_with_message() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + let message = String::from_str(&e, "Great puzzle!"); + + // Send a tip with message + client.tip_with_message(&tipper, &recipient, &100_000, &message); + + // Check tip history + let history = client.get_tip_history(&recipient); + assert_eq!(history.len(), 1); + + let tip = history.get(0).unwrap(); + assert_eq!(tip.from, tipper); + assert_eq!(tip.to, recipient); + assert_eq!(tip.amount, 100_000); + assert_eq!(tip.message, message); +} + +#[test] +fn test_batch_tipping() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient1 = Address::generate(&e); + let recipient2 = Address::generate(&e); + let recipient3 = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Create recipient list and amounts + let mut recipients = soroban_sdk::Vec::new(&e); + recipients.push_back(recipient1.clone()); + recipients.push_back(recipient2.clone()); + recipients.push_back(recipient3.clone()); + + let mut amounts = soroban_sdk::Vec::new(&e); + amounts.push_back(100_000); + amounts.push_back(150_000); + amounts.push_back(75_000); + + // Send batch tips + client.batch_tip(&tipper, &recipients, &amounts); + + // Check tipper stats + let tipper_stats = client.get_tipper_stats(&tipper); + assert_eq!(tipper_stats.total_tipped, 325_000); // Sum of all tips + assert_eq!(tipper_stats.tip_count, 3); + + // Check recipient1 stats + let recipient1_stats = client.get_recipient_stats(&recipient1); + assert_eq!(recipient1_stats.total_received, 100_000); + assert_eq!(recipient1_stats.tip_count, 1); + + // Check recipient2 stats + let recipient2_stats = client.get_recipient_stats(&recipient2); + assert_eq!(recipient2_stats.total_received, 150_000); + assert_eq!(recipient2_stats.tip_count, 1); + + // Check recipient3 stats + let recipient3_stats = client.get_recipient_stats(&recipient3); + assert_eq!(recipient3_stats.total_received, 75_000); + assert_eq!(recipient3_stats.tip_count, 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_batch_tipping_size_mismatch() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient1 = Address::generate(&e); + + // Mint tokens to tipper + let _token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + let mut recipients = soroban_sdk::Vec::new(&e); + recipients.push_back(recipient1); + + let mut amounts = soroban_sdk::Vec::new(&e); + amounts.push_back(100_000); + amounts.push_back(150_000); // Mismatch! + + client.batch_tip(&tipper, &recipients, &amounts); +} + +#[test] +fn test_tip_history_tracking() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper1 = Address::generate(&e); + let tipper2 = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tippers + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper1, &10_000_000); + token_admin_client.mint(&tipper2, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Tipper1 sends tip with message + let msg1 = String::from_str(&e, "Great work!"); + client.tip_with_message(&tipper1, &recipient, &100_000, &msg1); + + // Tipper2 sends tip with message + let msg2 = String::from_str(&e, "Amazing!"); + client.tip_with_message(&tipper2, &recipient, &200_000, &msg2); + + // Check tip history + let history = client.get_tip_history(&recipient); + assert_eq!(history.len(), 2); + + let tip1 = history.get(0).unwrap(); + assert_eq!(tip1.from, tipper1); + assert_eq!(tip1.amount, 100_000); + assert_eq!(tip1.message, msg1); + + let tip2 = history.get(1).unwrap(); + assert_eq!(tip2.from, tipper2); + assert_eq!(tip2.amount, 200_000); + assert_eq!(tip2.message, msg2); +} + +#[test] +fn test_top_tippers_leaderboard() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper1 = Address::generate(&e); + let tipper2 = Address::generate(&e); + let tipper3 = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tippers + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper1, &10_000_000); + token_admin_client.mint(&tipper2, &10_000_000); + token_admin_client.mint(&tipper3, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send tips from different tippers + client.tip(&tipper1, &recipient, &500_000); + client.tip(&tipper2, &recipient, &300_000); + client.tip(&tipper3, &recipient, &700_000); + + // Get top tippers + let top_tippers = client.get_top_tippers(&5); + assert!(top_tippers.len() >= 3); + + // Verify ordering (highest first) + let (top1_addr, top1_amount) = top_tippers.get(0).unwrap(); + assert_eq!(top1_addr, tipper3); + assert_eq!(top1_amount, 700_000); +} + +#[test] +fn test_top_recipients_leaderboard() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient1 = Address::generate(&e); + let recipient2 = Address::generate(&e); + let recipient3 = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send tips to different recipients + client.tip(&tipper, &recipient1, &200_000); + client.tip(&tipper, &recipient2, &500_000); + client.tip(&tipper, &recipient3, &100_000); + + // Get top recipients + let top_recipients = client.get_top_recipients(&5); + assert!(top_recipients.len() >= 3); + + // Verify ordering (highest first) + let (top1_addr, top1_amount) = top_recipients.get(0).unwrap(); + assert_eq!(top1_addr, recipient2); + assert_eq!(top1_amount, 500_000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_daily_tip_limit() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + + // Create 11 recipients + let mut recipients = soroban_sdk::Vec::new(&e); + for _ in 0..11 { + recipients.push_back(Address::generate(&e)); + } + + // Mint tokens to tipper + let _token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &100_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send 11 tips (limit is 10 per day) + for i in 0..recipients.len() { + let recipient = recipients.get(i).unwrap(); + client.tip(&tipper, &recipient, &100_000); + } +} + +#[test] +fn test_cooldown_mechanism() { + let e = Env::default(); + + // Set up with 60 second cooldown + e.mock_all_auths(); + e.ledger().set_timestamp(1000); // Start with a non-zero timestamp + + let admin = Address::generate(&e); + let token_admin = Address::generate(&e); + let (token_address, _) = create_token_contract(&e, &token_admin); + let tipping_contract = create_tipping_contract(&e); + + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&admin, &100_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + client.initialize( + &admin, + &token_address, + &1_000_000, + &10, + &60, // 60 second cooldown + &5, + ); + + let tipper = Address::generate(&e); + let recipient1 = Address::generate(&e); + let recipient2 = Address::generate(&e); + + token_admin_client.mint(&tipper, &10_000_000); + + // First tip should succeed + client.tip(&tipper, &recipient1, &100_000); + + // Verify tipper stats were updated with timestamp + let tipper_stats = client.get_tipper_stats(&tipper); + assert_eq!(tipper_stats.tip_count, 1); + assert!(tipper_stats.last_tip_time == 1000); + + // Advance ledger time by 61 seconds to bypass cooldown + e.ledger().set_timestamp(1061); + + // Now the second tip should succeed + client.tip(&tipper, &recipient2, &100_000); + + // Verify both tips were recorded + let tipper_stats = client.get_tipper_stats(&tipper); + assert_eq!(tipper_stats.tip_count, 2); + assert_eq!(tipper_stats.total_tipped, 200_000); +} + +#[test] +fn test_tipper_stats_accumulation() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tipper + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send multiple tips + client.tip(&tipper, &recipient, &100_000); + + e.ledger().set_timestamp(61); // Advance time to bypass cooldown + + client.tip(&tipper, &recipient, &200_000); + + e.ledger().set_timestamp(122); // Advance time again + + client.tip(&tipper, &recipient, &300_000); + + // Check accumulated stats + let tipper_stats = client.get_tipper_stats(&tipper); + assert_eq!(tipper_stats.total_tipped, 600_000); + assert_eq!(tipper_stats.tip_count, 3); +} + +#[test] +fn test_recipient_stats_accumulation() { + let e = Env::default(); + let (tipping_contract, token_address, _, _) = setup_contract(&e); + + let tipper1 = Address::generate(&e); + let tipper2 = Address::generate(&e); + let recipient = Address::generate(&e); + + // Mint tokens to tippers + let token_admin = Address::generate(&e); + let token_admin_client = StellarAssetClient::new(&e, &token_address); + token_admin_client.mint(&tipper1, &10_000_000); + token_admin_client.mint(&tipper2, &10_000_000); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + // Send tips from different sources + client.tip(&tipper1, &recipient, &100_000); + client.tip(&tipper2, &recipient, &200_000); + + e.ledger().set_timestamp(61); // Advance time + + client.tip(&tipper1, &recipient, &300_000); + + // Check accumulated stats + let recipient_stats = client.get_recipient_stats(&recipient); + assert_eq!(recipient_stats.total_received, 600_000); + assert_eq!(recipient_stats.tip_count, 3); +} + +#[test] +fn test_get_config() { + let e = Env::default(); + e.mock_all_auths(); + + let admin = Address::generate(&e); + let token_admin = Address::generate(&e); + let (token_address, _) = create_token_contract(&e, &token_admin); + let tipping_contract = create_tipping_contract(&e); + + let client = SocialTippingContractClient::new(&e, &tipping_contract); + + client.initialize( + &admin, + &token_address, + &2_000_000, + &20, + &120, + &10, + ); + + let config = client.get_config(); + assert_eq!(config.admin, admin); + assert_eq!(config.token, token_address); + assert_eq!(config.max_tip_per_transaction, 2_000_000); + assert_eq!(config.max_tips_per_day, 20); + assert_eq!(config.cooldown_seconds, 120); + assert_eq!(config.max_batch_size, 10); + assert!(config.is_initialized); +}