diff --git a/Cargo.lock b/Cargo.lock index e90f6aa1..25483fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,13 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "badge-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -694,6 +701,13 @@ dependencies = [ "digest", ] +[[package]] +name = "holder-rewards" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -820,6 +834,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "key-tipping" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "launch-auction" version = "0.1.0" @@ -827,6 +848,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "leaderboard-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "libc" version = "0.2.183" @@ -1600,6 +1628,13 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vesting-contract" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index bd15af11..98a7104a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,11 @@ members = [ "auto-invest-registry", "key-analytics", "dividend-contract", + "vesting-contract", + "badge-contract", + "leaderboard-contract", + "holder-rewards", + "key-tipping", ] resolver = "2" diff --git a/key-tipping/Cargo.toml b/key-tipping/Cargo.toml new file mode 100644 index 00000000..5a20fef9 --- /dev/null +++ b/key-tipping/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "key-tipping" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/key-tipping/src/lib.rs b/key-tipping/src/lib.rs new file mode 100644 index 00000000..96fb312b --- /dev/null +++ b/key-tipping/src/lib.rs @@ -0,0 +1,222 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, Address, Env, String, Symbol, Vec, +}; + +const PROTOCOL_FEE_BPS: i128 = 200; +const BPS_DENOMINATOR: i128 = 10_000; +const MAX_PAGE_SIZE: u32 = 100; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum TipError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidAmount = 4, + UnknownKey = 5, + Overflow = 6, + InvalidPage = 7, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TipRecord { + pub tipper: Address, + pub creator: Address, + /// Amount transferred to the creator after the protocol fee. + pub amount: i128, + pub protocol_fee: i128, + pub message_hash: Option, + pub ledger: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TipSentEvent { + pub creator: Address, + pub amount: i128, + pub protocol_fee: i128, + pub message_hash: Option, +} + +#[contracttype] +#[derive(Clone)] +enum DataKey { + Admin, + Asset, + Treasury, + Creator(Address), + Count(Address), + Tip(Address, u32), + Total(Address), +} + +#[contract] +pub struct KeyTippingContract; + +#[contractimpl] +impl KeyTippingContract { + /// Configure the payment token and protocol treasury once. + pub fn initialize( + env: Env, + admin: Address, + asset: Address, + treasury: Address, + ) -> Result<(), TipError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(TipError::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Asset, &asset); + env.storage().instance().set(&DataKey::Treasury, &treasury); + Ok(()) + } + + /// Register which creator receives tips for a key identifier. + pub fn set_creator(env: Env, key_id: Address, creator: Address) -> Result<(), TipError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(TipError::NotInitialized)?; + admin.require_auth(); + env.storage() + .persistent() + .set(&DataKey::Creator(key_id), &creator); + Ok(()) + } + + /// Transfer a tip to a key's creator and append an on-chain receipt. + pub fn send_tip( + env: Env, + key_id: Address, + tipper: Address, + amount: i128, + message_hash: Option, + ) -> Result { + tipper.require_auth(); + if amount <= 0 { + return Err(TipError::InvalidAmount); + } + + let creator: Address = env + .storage() + .persistent() + .get(&DataKey::Creator(key_id.clone())) + .ok_or(TipError::UnknownKey)?; + let asset: Address = env + .storage() + .instance() + .get(&DataKey::Asset) + .ok_or(TipError::NotInitialized)?; + let treasury: Address = env + .storage() + .instance() + .get(&DataKey::Treasury) + .ok_or(TipError::NotInitialized)?; + + let product = amount + .checked_mul(PROTOCOL_FEE_BPS) + .ok_or(TipError::Overflow)?; + let fee = product / BPS_DENOMINATOR; + let creator_amount = amount.checked_sub(fee).ok_or(TipError::Overflow)?; + + let token = token::Client::new(&env, &asset); + let contract = env.current_contract_address(); + token.transfer(&tipper, &contract, &amount); + token.transfer(&contract, &creator, &creator_amount); + if fee > 0 { + token.transfer(&contract, &treasury, &fee); + } + + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::Count(key_id.clone())) + .unwrap_or(0); + let next = count.checked_add(1).ok_or(TipError::Overflow)?; + let record = TipRecord { + tipper: tipper.clone(), + creator: creator.clone(), + amount: creator_amount, + protocol_fee: fee, + message_hash: message_hash.clone(), + ledger: env.ledger().sequence(), + }; + env.storage() + .persistent() + .set(&DataKey::Tip(key_id.clone(), count), &record); + env.storage() + .persistent() + .set(&DataKey::Count(key_id.clone()), &next); + let total: i128 = env + .storage() + .persistent() + .get(&DataKey::Total(key_id.clone())) + .unwrap_or(0); + let new_total = total + .checked_add(creator_amount) + .ok_or(TipError::Overflow)?; + env.storage() + .persistent() + .set(&DataKey::Total(key_id.clone()), &new_total); + + env.events().publish( + (Symbol::new(&env, "TipSent"), key_id, tipper), + TipSentEvent { + creator, + amount: creator_amount, + protocol_fee: fee, + message_hash, + }, + ); + Ok(record) + } + + /// Read newest-first tip history. `offset` is measured from the newest tip. + pub fn get_tip_history( + env: Env, + key_id: Address, + offset: u32, + limit: u32, + ) -> Result, TipError> { + if limit == 0 || limit > MAX_PAGE_SIZE { + return Err(TipError::InvalidPage); + } + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::Count(key_id.clone())) + .unwrap_or(0); + let mut out = Vec::new(&env); + let mut index = count.saturating_sub(offset); + let mut remaining = limit; + while index > 0 && remaining > 0 { + index -= 1; + if let Some(record) = env + .storage() + .persistent() + .get(&DataKey::Tip(key_id.clone(), index)) + { + out.push_back(record); + remaining -= 1; + } + } + Ok(out) + } + + /// Cumulative amount received by the creator, after protocol fees. + pub fn get_total_tips(env: Env, key_id: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Total(key_id)) + .unwrap_or(0) + } +} + +#[cfg(test)] +mod test; diff --git a/key-tipping/src/test.rs b/key-tipping/src/test.rs new file mode 100644 index 00000000..2d2618b1 --- /dev/null +++ b/key-tipping/src/test.rs @@ -0,0 +1,101 @@ +#![cfg(test)] + +use crate::{KeyTippingContract, KeyTippingContractClient, TipError}; +use soroban_sdk::{ + testutils::Address as _, + token::{StellarAssetClient, TokenClient}, + Address, Env, String, +}; + +fn setup( + env: &Env, +) -> ( + KeyTippingContractClient<'_>, + Address, + Address, + Address, + Address, +) { + env.mock_all_auths(); + let contract_id = env.register(KeyTippingContract, ()); + let client = KeyTippingContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let treasury = Address::generate(env); + let creator = Address::generate(env); + let token_id = env + .register_stellar_asset_contract_v2(Address::generate(env)) + .address(); + client.initialize(&admin, &token_id, &treasury); + (client, token_id, treasury, creator, admin) +} + +#[test] +fn tip_transfers_fee_records_history_and_updates_total() { + let env = Env::default(); + let (client, token_id, treasury, creator, _) = setup(&env); + let key = Address::generate(&env); + let tipper = Address::generate(&env); + client.set_creator(&key, &creator); + StellarAssetClient::new(&env, &token_id).mint(&tipper, &1_000); + + let receipt = client.send_tip( + &key, + &tipper, + &1_000, + &Some(String::from_str(&env, "thanks")), + ); + let token = TokenClient::new(&env, &token_id); + assert_eq!(receipt.amount, 980); + assert_eq!(receipt.protocol_fee, 20); + assert_eq!(token.balance(&creator), 980); + assert_eq!(token.balance(&treasury), 20); + assert_eq!(client.get_total_tips(&key), 980); + let history = client.get_tip_history(&key, &0, &10); + assert_eq!(history.len(), 1); + assert_eq!(history.get(0).unwrap(), receipt); +} + +#[test] +fn unknown_keys_nonpositive_amounts_and_invalid_pages_are_rejected() { + let env = Env::default(); + let (client, _, _, _, _) = setup(&env); + let key = Address::generate(&env); + let tipper = Address::generate(&env); + assert_eq!( + client.try_send_tip(&key, &tipper, &10, &None), + Err(Ok(TipError::UnknownKey)) + ); + assert_eq!( + client.try_send_tip(&key, &tipper, &0, &None), + Err(Ok(TipError::InvalidAmount)) + ); + assert_eq!( + client.try_get_tip_history(&key, &0, &0), + Err(Ok(TipError::InvalidPage)) + ); + assert_eq!( + client.try_get_tip_history(&key, &0, &101), + Err(Ok(TipError::InvalidPage)) + ); +} + +#[test] +fn history_pages_are_newest_first_and_offset_from_newest() { + let env = Env::default(); + let (client, token_id, _, creator, _) = setup(&env); + let key = Address::generate(&env); + let tipper = Address::generate(&env); + client.set_creator(&key, &creator); + StellarAssetClient::new(&env, &token_id).mint(&tipper, &3_000); + client.send_tip(&key, &tipper, &100, &None); + client.send_tip(&key, &tipper, &200, &None); + client.send_tip(&key, &tipper, &300, &None); + + let newest = client.get_tip_history(&key, &0, &2); + assert_eq!(newest.len(), 2); + assert_eq!(newest.get(0).unwrap().amount, 294); + assert_eq!(newest.get(1).unwrap().amount, 196); + let older = client.get_tip_history(&key, &2, &2); + assert_eq!(older.len(), 1); + assert_eq!(older.get(0).unwrap().amount, 98); +}