From 687ae41da1e988ed367b3d6472f3b1c9dd21fe2e Mon Sep 17 00:00:00 2001 From: MadakiElisha Date: Tue, 28 Apr 2026 14:53:03 +0100 Subject: [PATCH] all 4 fixed --- contracts/artist-allowlist/src/access.rs | 11 ++ contracts/artist-allowlist/src/indexes.rs | 23 ++- contracts/artist-allowlist/src/lib.rs | 167 ++++++++++----------- contracts/artist-allowlist/src/storage.rs | 85 +++++++++++ contracts/artist-allowlist/src/test.rs | 168 ++++++++++++++-------- contracts/fan-token/src/lib.rs | 16 +++ contracts/fan-token/src/queries.rs | 61 ++++++++ contracts/fan-token/src/storage.rs | 50 +++++++ contracts/fan-token/src/test.rs | 47 ++++++ contracts/tip-time-lock/src/events.rs | 69 +++++++++ contracts/tip-time-lock/src/lib.rs | 107 ++------------ contracts/tip-time-lock/src/test.rs | 16 +++ 12 files changed, 569 insertions(+), 251 deletions(-) create mode 100644 contracts/artist-allowlist/src/access.rs create mode 100644 contracts/artist-allowlist/src/storage.rs create mode 100644 contracts/fan-token/src/queries.rs create mode 100644 contracts/tip-time-lock/src/events.rs diff --git a/contracts/artist-allowlist/src/access.rs b/contracts/artist-allowlist/src/access.rs new file mode 100644 index 0000000..85c1565 --- /dev/null +++ b/contracts/artist-allowlist/src/access.rs @@ -0,0 +1,11 @@ +use soroban_sdk::{Address, Env}; + +use crate::Error; + +pub fn require_artist_or_manager(env: &Env, artist: &Address, caller: &Address) -> Result<(), Error> { + caller.require_auth(); + if caller == artist || crate::storage::is_manager(env, artist, caller) { + return Ok(()); + } + Err(Error::Unauthorized) +} diff --git a/contracts/artist-allowlist/src/indexes.rs b/contracts/artist-allowlist/src/indexes.rs index 23804c4..f82acbb 100644 --- a/contracts/artist-allowlist/src/indexes.rs +++ b/contracts/artist-allowlist/src/indexes.rs @@ -6,6 +6,15 @@ pub enum IndexKey { ArtistEntries(Address), } +const LIFETIME_THRESHOLD: u32 = 100_000; +const EXTEND_TO: u32 = 200_000; + +fn bump(env: &Env, key: &IndexKey) { + env.storage() + .persistent() + .extend_ttl(key, LIFETIME_THRESHOLD, EXTEND_TO); +} + pub fn add_to_index(env: &Env, artist: &Address, address: &Address) { let key = IndexKey::ArtistEntries(artist.clone()); let mut entries: Vec
= env @@ -15,6 +24,7 @@ pub fn add_to_index(env: &Env, artist: &Address, address: &Address) { .unwrap_or_else(|| Vec::new(env)); entries.push_back(address.clone()); env.storage().persistent().set(&key, &entries); + bump(env, &key); } pub fn remove_from_index(env: &Env, artist: &Address, address: &Address) { @@ -35,6 +45,7 @@ pub fn remove_from_index(env: &Env, artist: &Address, address: &Address) { } } env.storage().persistent().set(&key, &entries); + bump(env, &key); } } @@ -48,6 +59,9 @@ pub fn get_page(env: &Env, artist: &Address, page: u32, page_size: u32) -> Vec Vec u32 { let key = IndexKey::ArtistEntries(artist.clone()); - env.storage() + let entries = env.storage() .persistent() .get::>(&key) - .map(|v| v.len()) - .unwrap_or(0) + .unwrap_or_else(|| Vec::new(env)); + if !entries.is_empty() { + bump(env, &key); + } + entries.len() } diff --git a/contracts/artist-allowlist/src/lib.rs b/contracts/artist-allowlist/src/lib.rs index 4c00843..322ab29 100644 --- a/contracts/artist-allowlist/src/lib.rs +++ b/contracts/artist-allowlist/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] +mod access; mod indexes; +mod storage; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Vec, @@ -58,6 +60,7 @@ pub enum DataKey { Config(Address), Entry(Address, Address), TokenGate(Address), + Manager(Address, Address), } #[contract] @@ -65,15 +68,34 @@ pub struct ArtistAllowlistContract; #[contractimpl] impl ArtistAllowlistContract { - /// Set or update the allowlist mode for an artist - pub fn set_allowlist_mode(env: Env, artist: Address, mode: AllowlistMode) -> Result<(), Error> { + pub fn set_manager( + env: Env, + artist: Address, + manager: Address, + enabled: bool, + ) -> Result<(), Error> { artist.require_auth(); + storage::set_manager(&env, &artist, &manager, enabled); + env.events().publish( + (symbol_short!("allowlst"), symbol_short!("manager")), + (artist, manager, enabled), + ); + Ok(()) + } - let config: AllowlistConfig = match env - .storage() - .persistent() - .get(&DataKey::Config(artist.clone())) - { + pub fn is_manager(env: Env, artist: Address, manager: Address) -> bool { + storage::is_manager(&env, &artist, &manager) + } + + /// Set or update the allowlist mode for an artist + pub fn set_allowlist_mode( + env: Env, + artist: Address, + caller: Address, + mode: AllowlistMode, + ) -> Result<(), Error> { + access::require_artist_or_manager(&env, &artist, &caller)?; + let config: AllowlistConfig = match storage::get_config(&env, &artist) { Some(existing) => AllowlistConfig { mode, ..existing }, None => AllowlistConfig { artist: artist.clone(), @@ -82,14 +104,11 @@ impl ArtistAllowlistContract { created_at: env.ledger().timestamp(), }, }; - - env.storage() - .persistent() - .set(&DataKey::Config(artist.clone()), &config); + storage::set_config(&env, &artist, &config); env.events().publish( (symbol_short!("allowlst"), symbol_short!("mode")), - (artist, mode), + (artist, caller, mode), ); Ok(()) @@ -100,10 +119,11 @@ impl ArtistAllowlistContract { pub fn set_token_gate( env: Env, artist: Address, + caller: Address, token_address: Address, min_balance: i128, ) -> Result<(), Error> { - artist.require_auth(); + access::require_artist_or_manager(&env, &artist, &caller)?; if min_balance <= 0 { return Err(Error::InvalidTokenConfig); @@ -118,27 +138,25 @@ impl ArtistAllowlistContract { min_balance, }; - env.storage() - .persistent() - .set(&DataKey::TokenGate(artist.clone()), &gate); + storage::set_token_gate(&env, &artist, &gate); env.events().publish( (symbol_short!("allowlst"), symbol_short!("tkngate")), - (artist, min_balance), + (artist, caller, min_balance), ); Ok(()) } /// Add an address to an artist's allowlist - pub fn add_to_allowlist(env: Env, artist: Address, address: Address) -> Result<(), Error> { - artist.require_auth(); - - if env - .storage() - .persistent() - .has(&DataKey::Entry(artist.clone(), address.clone())) - { + pub fn add_to_allowlist( + env: Env, + artist: Address, + caller: Address, + address: Address, + ) -> Result<(), Error> { + access::require_artist_or_manager(&env, &artist, &caller)?; + if storage::has_entry(&env, &artist, &address) { return Err(Error::AlreadyOnAllowlist); } @@ -146,44 +164,38 @@ impl ArtistAllowlistContract { artist: artist.clone(), address: address.clone(), added_at: env.ledger().timestamp(), - added_by: artist.clone(), + added_by: caller.clone(), }; - - env.storage() - .persistent() - .set(&DataKey::Entry(artist.clone(), address.clone()), &entry); + storage::set_entry(&env, &artist, &address, &entry); indexes::add_to_index(&env, &artist, &address); env.events().publish( (symbol_short!("allowlst"), symbol_short!("added")), - (artist, address), + (artist, caller, address), ); Ok(()) } /// Remove an address from an artist's allowlist - pub fn remove_from_allowlist(env: Env, artist: Address, address: Address) -> Result<(), Error> { - artist.require_auth(); - - if !env - .storage() - .persistent() - .has(&DataKey::Entry(artist.clone(), address.clone())) - { + pub fn remove_from_allowlist( + env: Env, + artist: Address, + caller: Address, + address: Address, + ) -> Result<(), Error> { + access::require_artist_or_manager(&env, &artist, &caller)?; + if !storage::has_entry(&env, &artist, &address) { return Err(Error::NotOnAllowlist); } - - env.storage() - .persistent() - .remove(&DataKey::Entry(artist.clone(), address.clone())); + storage::remove_entry(&env, &artist, &address); indexes::remove_from_index(&env, &artist, &address); env.events().publish( (symbol_short!("allowlst"), symbol_short!("removed")), - (artist, address), + (artist, caller, address), ); Ok(()) @@ -191,11 +203,7 @@ impl ArtistAllowlistContract { /// Check if a tipper is allowed to tip an artist pub fn check_can_tip(env: Env, artist: Address, tipper: Address) -> bool { - let config: AllowlistConfig = match env - .storage() - .persistent() - .get(&DataKey::Config(artist.clone())) - { + let config: AllowlistConfig = match storage::get_config(&env, &artist) { Some(c) => c, None => return true, }; @@ -206,16 +214,12 @@ impl ArtistAllowlistContract { match config.mode { AllowlistMode::Open => true, - AllowlistMode::AllowlistOnly => env - .storage() - .persistent() - .has(&DataKey::Entry(artist, tipper)), + AllowlistMode::AllowlistOnly => storage::has_entry(&env, &artist, &tipper), AllowlistMode::TokenGated => { - let gate: TokenGateConfig = - match env.storage().persistent().get(&DataKey::TokenGate(artist)) { - Some(g) => g, - None => return false, - }; + let gate: TokenGateConfig = match storage::get_token_gate(&env, &artist) { + Some(g) => g, + None => return false, + }; let client = token::Client::new(&env, &gate.token_address); client.balance(&tipper) >= gate.min_balance } @@ -224,17 +228,12 @@ impl ArtistAllowlistContract { /// Get the current allowlist config for an artist pub fn get_config(env: Env, artist: Address) -> Result { - env.storage() - .persistent() - .get(&DataKey::Config(artist)) - .ok_or(Error::ConfigNotFound) + storage::get_config(&env, &artist).ok_or(Error::ConfigNotFound) } /// Check if an address is on the allowlist pub fn is_on_allowlist(env: Env, artist: Address, address: Address) -> bool { - env.storage() - .persistent() - .has(&DataKey::Entry(artist, address)) + storage::has_entry(&env, &artist, &address) } /// Add multiple addresses to an artist's allowlist in a batch operation. @@ -242,9 +241,10 @@ impl ArtistAllowlistContract { pub fn add_batch_to_allowlist( env: Env, artist: Address, + caller: Address, addresses: Vec
, ) -> Result<(), Error> { - artist.require_auth(); + access::require_artist_or_manager(&env, &artist, &caller)?; if addresses.is_empty() { return Err(Error::EmptyBatchOperation); @@ -262,11 +262,7 @@ impl ArtistAllowlistContract { // Check if any already on allowlist (fail-fast) for address in addresses.iter() { - if env - .storage() - .persistent() - .has(&DataKey::Entry(artist.clone(), address.clone())) - { + if storage::has_entry(&env, &artist, &address) { return Err(Error::AlreadyOnAllowlist); } } @@ -277,18 +273,15 @@ impl ArtistAllowlistContract { artist: artist.clone(), address: address.clone(), added_at: env.ledger().timestamp(), - added_by: artist.clone(), + added_by: caller.clone(), }; - - env.storage() - .persistent() - .set(&DataKey::Entry(artist.clone(), address.clone()), &entry); + storage::set_entry(&env, &artist, &address, &entry); indexes::add_to_index(&env, &artist, &address); env.events().publish( (symbol_short!("allowlst"), symbol_short!("batch")), - (artist.clone(), address.clone()), + (artist.clone(), caller.clone(), address.clone()), ); } @@ -300,9 +293,10 @@ impl ArtistAllowlistContract { pub fn remove_batch_from_allowlist( env: Env, artist: Address, + caller: Address, addresses: Vec
, ) -> Result<(), Error> { - artist.require_auth(); + access::require_artist_or_manager(&env, &artist, &caller)?; if addresses.is_empty() { return Err(Error::EmptyBatchOperation); @@ -310,26 +304,20 @@ impl ArtistAllowlistContract { // Check if all addresses are on the allowlist (fail-fast) for address in addresses.iter() { - if !env - .storage() - .persistent() - .has(&DataKey::Entry(artist.clone(), address.clone())) - { + if !storage::has_entry(&env, &artist, &address) { return Err(Error::NotOnAllowlist); } } // Remove all addresses (if we get here, all checks passed) for address in addresses.iter() { - env.storage() - .persistent() - .remove(&DataKey::Entry(artist.clone(), address.clone())); + storage::remove_entry(&env, &artist, &address); indexes::remove_from_index(&env, &artist, &address); env.events().publish( (symbol_short!("allowlst"), symbol_short!("brem")), - (artist.clone(), address.clone()), + (artist.clone(), caller.clone(), address.clone()), ); } @@ -339,10 +327,7 @@ impl ArtistAllowlistContract { /// Get the token gate configuration for an artist. /// Returns error if token gate is not configured. pub fn get_token_gate(env: Env, artist: Address) -> Result { - env.storage() - .persistent() - .get(&DataKey::TokenGate(artist)) - .ok_or(Error::TokenGateNotFound) + storage::get_token_gate(&env, &artist).ok_or(Error::TokenGateNotFound) } /// Return a page of allowlist entries for an artist. diff --git a/contracts/artist-allowlist/src/storage.rs b/contracts/artist-allowlist/src/storage.rs new file mode 100644 index 0000000..909a1c7 --- /dev/null +++ b/contracts/artist-allowlist/src/storage.rs @@ -0,0 +1,85 @@ +use soroban_sdk::{Address, Env}; + +use crate::{AllowlistConfig, AllowlistEntry, DataKey, TokenGateConfig}; + +const LIFETIME_THRESHOLD: u32 = 100_000; +const EXTEND_TO: u32 = 200_000; + +fn bump(env: &Env, key: &DataKey) { + env.storage() + .persistent() + .extend_ttl(key, LIFETIME_THRESHOLD, EXTEND_TO); +} + +pub fn get_config(env: &Env, artist: &Address) -> Option { + let key = DataKey::Config(artist.clone()); + let config = env.storage().persistent().get(&key); + if config.is_some() { + bump(env, &key); + } + config +} + +pub fn set_config(env: &Env, artist: &Address, config: &AllowlistConfig) { + let key = DataKey::Config(artist.clone()); + env.storage().persistent().set(&key, config); + bump(env, &key); +} + +pub fn get_entry(env: &Env, artist: &Address, address: &Address) -> Option { + let key = DataKey::Entry(artist.clone(), address.clone()); + let entry = env.storage().persistent().get(&key); + if entry.is_some() { + bump(env, &key); + } + entry +} + +pub fn has_entry(env: &Env, artist: &Address, address: &Address) -> bool { + get_entry(env, artist, address).is_some() +} + +pub fn set_entry(env: &Env, artist: &Address, address: &Address, entry: &AllowlistEntry) { + let key = DataKey::Entry(artist.clone(), address.clone()); + env.storage().persistent().set(&key, entry); + bump(env, &key); +} + +pub fn remove_entry(env: &Env, artist: &Address, address: &Address) { + let key = DataKey::Entry(artist.clone(), address.clone()); + env.storage().persistent().remove(&key); +} + +pub fn get_token_gate(env: &Env, artist: &Address) -> Option { + let key = DataKey::TokenGate(artist.clone()); + let gate = env.storage().persistent().get(&key); + if gate.is_some() { + bump(env, &key); + } + gate +} + +pub fn set_token_gate(env: &Env, artist: &Address, gate: &TokenGateConfig) { + let key = DataKey::TokenGate(artist.clone()); + env.storage().persistent().set(&key, gate); + bump(env, &key); +} + +pub fn is_manager(env: &Env, artist: &Address, manager: &Address) -> bool { + let key = DataKey::Manager(artist.clone(), manager.clone()); + let exists = env.storage().persistent().has(&key); + if exists { + bump(env, &key); + } + exists +} + +pub fn set_manager(env: &Env, artist: &Address, manager: &Address, enabled: bool) { + let key = DataKey::Manager(artist.clone(), manager.clone()); + if enabled { + env.storage().persistent().set(&key, &true); + bump(env, &key); + } else { + env.storage().persistent().remove(&key); + } +} diff --git a/contracts/artist-allowlist/src/test.rs b/contracts/artist-allowlist/src/test.rs index a1a2fde..195e5e2 100644 --- a/contracts/artist-allowlist/src/test.rs +++ b/contracts/artist-allowlist/src/test.rs @@ -19,7 +19,7 @@ fn test_set_mode_open() { let client = ArtistAllowlistContractClient::new(&env, &contract_id); let artist = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::Open); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::Open); let config = client.get_config(&artist); assert_eq!(config.artist, artist); @@ -35,7 +35,7 @@ fn test_set_mode_allowlist_only() { let client = ArtistAllowlistContractClient::new(&env, &contract_id); let artist = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); let config = client.get_config(&artist); assert_eq!(config.mode, AllowlistMode::AllowlistOnly); @@ -49,16 +49,16 @@ fn test_mode_switching() { let artist = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::Open); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::Open); assert_eq!(client.get_config(&artist).mode, AllowlistMode::Open); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); assert_eq!( client.get_config(&artist).mode, AllowlistMode::AllowlistOnly ); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); assert_eq!(client.get_config(&artist).mode, AllowlistMode::TokenGated); } @@ -69,11 +69,11 @@ fn test_mode_switch_preserves_config() { let client = ArtistAllowlistContractClient::new(&env, &contract_id); let artist = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::Open); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::Open); let original = client.get_config(&artist); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); let updated = client.get_config(&artist); assert_eq!(updated.artist, original.artist); @@ -91,7 +91,7 @@ fn test_add_to_allowlist() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper); + client.add_to_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), true); } @@ -104,8 +104,8 @@ fn test_add_duplicate_fails() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper); - let result = client.try_add_to_allowlist(&artist, &tipper); + client.add_to_allowlist(&artist, &artist, &tipper); + let result = client.try_add_to_allowlist(&artist, &artist, &tipper); assert_eq!(result, Err(Ok(Error::AlreadyOnAllowlist))); } @@ -118,10 +118,10 @@ fn test_remove_from_allowlist() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper); + client.add_to_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), true); - client.remove_from_allowlist(&artist, &tipper); + client.remove_from_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), false); } @@ -134,7 +134,7 @@ fn test_remove_nonexistent_fails() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - let result = client.try_remove_from_allowlist(&artist, &tipper); + let result = client.try_remove_from_allowlist(&artist, &artist, &tipper); assert_eq!(result, Err(Ok(Error::NotOnAllowlist))); } @@ -147,7 +147,7 @@ fn test_check_can_tip_open_mode() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::Open); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::Open); assert_eq!(client.check_can_tip(&artist, &tipper), true); } @@ -172,8 +172,8 @@ fn test_check_can_tip_allowlist_only_allowed() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); - client.add_to_allowlist(&artist, &tipper); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); + client.add_to_allowlist(&artist, &artist, &tipper); assert_eq!(client.check_can_tip(&artist, &tipper), true); } @@ -187,7 +187,7 @@ fn test_check_can_tip_allowlist_only_denied() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); assert_eq!(client.check_can_tip(&artist, &tipper), false); } @@ -206,8 +206,8 @@ fn test_check_can_tip_token_gated_sufficient_balance() { let sac = token::StellarAssetClient::new(&env, &token_address); sac.mint(&tipper, &1000); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); - client.set_token_gate(&artist, &token_address, &500); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); + client.set_token_gate(&artist, &artist, &token_address, &500); assert_eq!(client.check_can_tip(&artist, &tipper), true); } @@ -227,8 +227,8 @@ fn test_check_can_tip_token_gated_insufficient_balance() { let sac = token::StellarAssetClient::new(&env, &token_address); sac.mint(&tipper, &100); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); - client.set_token_gate(&artist, &token_address, &500); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); + client.set_token_gate(&artist, &artist, &token_address, &500); assert_eq!(client.check_can_tip(&artist, &tipper), false); } @@ -248,8 +248,8 @@ fn test_check_can_tip_token_gated_exact_balance() { let sac = token::StellarAssetClient::new(&env, &token_address); sac.mint(&tipper, &500); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); - client.set_token_gate(&artist, &token_address, &500); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); + client.set_token_gate(&artist, &artist, &token_address, &500); assert_eq!(client.check_can_tip(&artist, &tipper), true); } @@ -263,7 +263,7 @@ fn test_check_can_tip_token_gated_no_gate_config() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); assert_eq!(client.check_can_tip(&artist, &tipper), false); } @@ -276,10 +276,10 @@ fn test_set_token_gate_invalid_balance() { let artist = Address::generate(&env); let token_address = Address::generate(&env); - let result = client.try_set_token_gate(&artist, &token_address, &0); + let result = client.try_set_token_gate(&artist, &artist, &token_address, &0); assert_eq!(result, Err(Ok(Error::InvalidTokenConfig))); - let result = client.try_set_token_gate(&artist, &token_address, &-10); + let result = client.try_set_token_gate(&artist, &artist, &token_address, &-10); assert_eq!(result, Err(Ok(Error::InvalidTokenConfig))); } @@ -303,13 +303,13 @@ fn test_allowlist_add_remove_readd() { let artist = Address::generate(&env); let tipper = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper); + client.add_to_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), true); - client.remove_from_allowlist(&artist, &tipper); + client.remove_from_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), false); - client.add_to_allowlist(&artist, &tipper); + client.add_to_allowlist(&artist, &artist, &tipper); assert_eq!(client.is_on_allowlist(&artist, &tipper), true); } @@ -323,10 +323,10 @@ fn test_multiple_artists_independent() { let artist2 = Address::generate(&env); let tipper = Address::generate(&env); - client.set_allowlist_mode(&artist1, &AllowlistMode::AllowlistOnly); - client.set_allowlist_mode(&artist2, &AllowlistMode::Open); + client.set_allowlist_mode(&artist1, &artist1, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist2, &artist2, &AllowlistMode::Open); - client.add_to_allowlist(&artist1, &tipper); + client.add_to_allowlist(&artist1, &artist1, &tipper); assert_eq!(client.check_can_tip(&artist1, &tipper), true); assert_eq!(client.check_can_tip(&artist2, &tipper), true); @@ -348,7 +348,7 @@ fn test_batch_add_to_allowlist() { let addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper2.clone(), tipper3.clone()]; - client.add_batch_to_allowlist(&artist, &addresses); + client.add_batch_to_allowlist(&artist, &artist, &addresses); assert_eq!(client.is_on_allowlist(&artist, &tipper1), true); assert_eq!(client.is_on_allowlist(&artist, &tipper2), true); @@ -364,7 +364,7 @@ fn test_batch_add_empty_fails() { let artist = Address::generate(&env); let addresses: Vec
= soroban_sdk::vec![&env]; - let result = client.try_add_batch_to_allowlist(&artist, &addresses); + let result = client.try_add_batch_to_allowlist(&artist, &artist, &addresses); assert_eq!(result, Err(Ok(Error::EmptyBatchOperation))); } @@ -379,7 +379,7 @@ fn test_batch_add_with_duplicate_in_batch_fails() { let addresses = soroban_sdk::vec![&env, tipper.clone(), tipper.clone()]; - let result = client.try_add_batch_to_allowlist(&artist, &addresses); + let result = client.try_add_batch_to_allowlist(&artist, &artist, &addresses); assert_eq!(result, Err(Ok(Error::AlreadyOnAllowlist))); } @@ -395,12 +395,12 @@ fn test_batch_add_with_existing_member_fails() { let tipper3 = Address::generate(&env); // Add first member - client.add_to_allowlist(&artist, &tipper1); + client.add_to_allowlist(&artist, &artist, &tipper1); // Try to batch add including already-existing member (atomicity check) let addresses = soroban_sdk::vec![&env, tipper2.clone(), tipper1.clone(), tipper3.clone()]; - let result = client.try_add_batch_to_allowlist(&artist, &addresses); + let result = client.try_add_batch_to_allowlist(&artist, &artist, &addresses); assert_eq!(result, Err(Ok(Error::AlreadyOnAllowlist))); // Verify atomicity: tipper2 and tipper3 should NOT have been added @@ -421,11 +421,11 @@ fn test_batch_remove_from_allowlist() { // Add all let addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper2.clone(), tipper3.clone()]; - client.add_batch_to_allowlist(&artist, &addresses); + client.add_batch_to_allowlist(&artist, &artist, &addresses); // Remove some let remove_addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper3.clone()]; - client.remove_batch_from_allowlist(&artist, &remove_addresses); + client.remove_batch_from_allowlist(&artist, &artist, &remove_addresses); assert_eq!(client.is_on_allowlist(&artist, &tipper1), false); assert_eq!(client.is_on_allowlist(&artist, &tipper2), true); @@ -441,7 +441,7 @@ fn test_batch_remove_empty_fails() { let artist = Address::generate(&env); let addresses: Vec
= soroban_sdk::vec![&env]; - let result = client.try_remove_batch_from_allowlist(&artist, &addresses); + let result = client.try_remove_batch_from_allowlist(&artist, &artist, &addresses); assert_eq!(result, Err(Ok(Error::EmptyBatchOperation))); } @@ -457,7 +457,7 @@ fn test_batch_remove_nonexistent_fails() { let addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper2.clone()]; - let result = client.try_remove_batch_from_allowlist(&artist, &addresses); + let result = client.try_remove_batch_from_allowlist(&artist, &artist, &addresses); assert_eq!(result, Err(Ok(Error::NotOnAllowlist))); } @@ -474,12 +474,12 @@ fn test_batch_remove_partial_atomicity() { // Add tipper1 and tipper2 let add_addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper2.clone()]; - client.add_batch_to_allowlist(&artist, &add_addresses); + client.add_batch_to_allowlist(&artist, &artist, &add_addresses); // Try to batch remove including non-existent member (atomicity check) let remove_addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper3.clone()]; - let result = client.try_remove_batch_from_allowlist(&artist, &remove_addresses); + let result = client.try_remove_batch_from_allowlist(&artist, &artist, &remove_addresses); assert_eq!(result, Err(Ok(Error::NotOnAllowlist))); // Verify atomicity: tipper1 should still be there @@ -499,9 +499,9 @@ fn test_list_allowlist_basic() { let tipper2 = Address::generate(&env); let tipper3 = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper1); - client.add_to_allowlist(&artist, &tipper2); - client.add_to_allowlist(&artist, &tipper3); + client.add_to_allowlist(&artist, &artist, &tipper1); + client.add_to_allowlist(&artist, &artist, &tipper2); + client.add_to_allowlist(&artist, &artist, &tipper3); let page = client.list_allowlist(&artist, &0, &10); assert_eq!(page.len(), 3); @@ -518,11 +518,11 @@ fn test_get_allowlist_count() { let tipper1 = Address::generate(&env); let tipper2 = Address::generate(&env); - client.add_to_allowlist(&artist, &tipper1); - client.add_to_allowlist(&artist, &tipper2); + client.add_to_allowlist(&artist, &artist, &tipper1); + client.add_to_allowlist(&artist, &artist, &tipper2); assert_eq!(client.get_allowlist_count(&artist), 2); - client.remove_from_allowlist(&artist, &tipper1); + client.remove_from_allowlist(&artist, &artist, &tipper1); assert_eq!(client.get_allowlist_count(&artist), 1); } @@ -541,7 +541,7 @@ fn test_list_allowlist_pagination() { Address::generate(&env), Address::generate(&env), ]; - client.add_batch_to_allowlist(&artist, &tippers); + client.add_batch_to_allowlist(&artist, &artist, &tippers); let page0 = client.list_allowlist(&artist, &0, &2); assert_eq!(page0.len(), 2); @@ -568,10 +568,10 @@ fn test_list_allowlist_count_reflects_removes() { let tipper3 = Address::generate(&env); let addresses = soroban_sdk::vec![&env, tipper1.clone(), tipper2.clone(), tipper3.clone()]; - client.add_batch_to_allowlist(&artist, &addresses); + client.add_batch_to_allowlist(&artist, &artist, &addresses); assert_eq!(client.get_allowlist_count(&artist), 3); - client.remove_from_allowlist(&artist, &tipper2); + client.remove_from_allowlist(&artist, &artist, &tipper2); assert_eq!(client.get_allowlist_count(&artist), 2); let page = client.list_allowlist(&artist, &0, &10); @@ -603,8 +603,8 @@ fn test_get_token_gate_found() { let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); let token_address = token_contract.address(); - client.set_allowlist_mode(&artist, &AllowlistMode::TokenGated); - client.set_token_gate(&artist, &token_address, &1000); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::TokenGated); + client.set_token_gate(&artist, &artist, &token_address, &1000); let gate = client.get_token_gate(&artist); assert_eq!(gate.token_address, token_address); @@ -637,7 +637,7 @@ fn test_token_gate_validation_with_valid_token() { let token_address = token_contract.address(); // Should succeed with valid token - let result = client.try_set_token_gate(&artist, &token_address, &1000); + let result = client.try_set_token_gate(&artist, &artist, &token_address, &1000); assert_eq!(result, Ok(Ok(()))); } @@ -653,7 +653,7 @@ fn test_batch_add_then_remove_workflow() { let tipper3 = Address::generate(&env); let tipper4 = Address::generate(&env); - client.set_allowlist_mode(&artist, &AllowlistMode::AllowlistOnly); + client.set_allowlist_mode(&artist, &artist, &AllowlistMode::AllowlistOnly); // Batch add 4 tippers let add_addresses = soroban_sdk::vec![ @@ -663,7 +663,7 @@ fn test_batch_add_then_remove_workflow() { tipper3.clone(), tipper4.clone() ]; - client.add_batch_to_allowlist(&artist, &add_addresses); + client.add_batch_to_allowlist(&artist, &artist, &add_addresses); // All should be able to tip assert_eq!(client.check_can_tip(&artist, &tipper1), true); @@ -673,7 +673,7 @@ fn test_batch_add_then_remove_workflow() { // Batch remove 2 tippers let remove_addresses = soroban_sdk::vec![&env, tipper2.clone(), tipper4.clone()]; - client.remove_batch_from_allowlist(&artist, &remove_addresses); + client.remove_batch_from_allowlist(&artist, &artist, &remove_addresses); // Check can tip results assert_eq!(client.check_can_tip(&artist, &tipper1), true); @@ -681,3 +681,55 @@ fn test_batch_add_then_remove_workflow() { assert_eq!(client.check_can_tip(&artist, &tipper3), true); assert_eq!(client.check_can_tip(&artist, &tipper4), false); } + +#[test] +fn test_delegated_manager_can_admin_allowlist() { + let env = setup_env(); + let contract_id = env.register_contract(None, ArtistAllowlistContract); + let client = ArtistAllowlistContractClient::new(&env, &contract_id); + + let artist = Address::generate(&env); + let manager = Address::generate(&env); + let tipper = Address::generate(&env); + + client.set_manager(&artist, &manager, &true); + assert!(client.is_manager(&artist, &manager)); + + client.set_allowlist_mode(&artist, &manager, &AllowlistMode::AllowlistOnly); + client.add_to_allowlist(&artist, &manager, &tipper); + + assert!(client.is_on_allowlist(&artist, &tipper)); + assert!(client.check_can_tip(&artist, &tipper)); +} + +#[test] +fn test_revoked_manager_cannot_admin_allowlist() { + let env = setup_env(); + let contract_id = env.register_contract(None, ArtistAllowlistContract); + let client = ArtistAllowlistContractClient::new(&env, &contract_id); + + let artist = Address::generate(&env); + let manager = Address::generate(&env); + let tipper = Address::generate(&env); + + client.set_manager(&artist, &manager, &true); + client.set_manager(&artist, &manager, &false); + assert!(!client.is_manager(&artist, &manager)); + + let result = client.try_add_to_allowlist(&artist, &manager, &tipper); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_unauthorized_wallet_cannot_admin_allowlist() { + let env = setup_env(); + let contract_id = env.register_contract(None, ArtistAllowlistContract); + let client = ArtistAllowlistContractClient::new(&env, &contract_id); + + let artist = Address::generate(&env); + let outsider = Address::generate(&env); + let tipper = Address::generate(&env); + + let result = client.try_add_to_allowlist(&artist, &outsider, &tipper); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} diff --git a/contracts/fan-token/src/lib.rs b/contracts/fan-token/src/lib.rs index 1007eb9..384de60 100644 --- a/contracts/fan-token/src/lib.rs +++ b/contracts/fan-token/src/lib.rs @@ -2,6 +2,7 @@ pub mod access; pub mod events; +pub mod queries; pub mod storage; pub mod types; pub mod metadata; @@ -85,6 +86,7 @@ impl FanTokenContract { last_updated: now, }; storage::set_balance(&env, &artist, &artist, &balance); + storage::sync_holder(&env, &artist, &artist, balance.balance); } events::token_created(&env, &token_id, &artist, &name, &symbol); @@ -159,6 +161,7 @@ impl FanTokenContract { fan_balance.last_updated = now; storage::set_balance(&env, &artist, &fan, &fan_balance); + storage::sync_holder(&env, &artist, &fan, fan_balance.balance); events::tokens_minted(&env, &artist, &fan, tip_amount, tokens_to_mint); @@ -211,6 +214,7 @@ impl FanTokenContract { .ok_or(Error::Overflow)?; from_balance.last_updated = now; storage::set_balance(&env, &artist, &from, &from_balance); + storage::sync_holder(&env, &artist, &from, from_balance.balance); // Credit receiver let mut to_balance = storage::get_balance(&env, &artist, &to).unwrap_or(FanBalance { @@ -227,6 +231,7 @@ impl FanTokenContract { .ok_or(Error::Overflow)?; to_balance.last_updated = now; storage::set_balance(&env, &artist, &to, &to_balance); + storage::sync_holder(&env, &artist, &to, to_balance.balance); events::tokens_transferred(&env, &from, &to, &artist, amount); @@ -275,6 +280,7 @@ impl FanTokenContract { bal.balance = bal.balance.checked_sub(amount).ok_or(Error::Overflow)?; bal.last_updated = env.ledger().timestamp(); storage::set_balance(&env, &artist, &holder, &bal); + storage::sync_holder(&env, &artist, &holder, bal.balance); token.circulating_supply = token .circulating_supply @@ -375,4 +381,14 @@ impl FanTokenContract { pub fn is_metadata_frozen(env: Env, artist: Address) -> bool { metadata::is_metadata_frozen(env, artist) } + + /// List token holders for an artist without scanning storage. + pub fn list_holders(env: Env, artist: Address, page: u32, page_size: u32) -> soroban_sdk::Vec
{ + queries::list_holders(&env, &artist, page, page_size) + } + + /// Return top fan balances ranked by token balance. + pub fn get_top_fans(env: Env, artist: Address, limit: u32) -> soroban_sdk::Vec { + queries::top_fans(&env, &artist, limit) + } } diff --git a/contracts/fan-token/src/queries.rs b/contracts/fan-token/src/queries.rs new file mode 100644 index 0000000..17b0f69 --- /dev/null +++ b/contracts/fan-token/src/queries.rs @@ -0,0 +1,61 @@ +use soroban_sdk::{Address, Env, Vec}; + +use crate::{storage, FanBalance}; + +pub fn list_holders(env: &Env, artist: &Address, page: u32, page_size: u32) -> Vec
{ + let holders = storage::get_holders(env, artist); + if page_size == 0 || page_size > 100 { + return Vec::new(env); + } + + let start = page.saturating_mul(page_size); + let end = (start + page_size).min(holders.len()); + let mut result = Vec::new(env); + + for i in start..end { + result.push_back(holders.get(i).unwrap()); + } + result +} + +pub fn top_fans(env: &Env, artist: &Address, limit: u32) -> Vec { + if limit == 0 { + return Vec::new(env); + } + + let holders = storage::get_holders(env, artist); + let mut ranked = Vec::new(env); + + for holder in holders.iter() { + if let Some(balance) = storage::get_balance(env, artist, &holder) { + if balance.balance > 0 { + ranked.push_back(balance); + } + } + } + + let len = ranked.len(); + for i in 0..len { + let mut max_idx = i; + for j in (i + 1)..len { + let left = ranked.get(max_idx).unwrap(); + let right = ranked.get(j).unwrap(); + if right.balance > left.balance { + max_idx = j; + } + } + if max_idx != i { + let vi = ranked.get(i).unwrap(); + let vmax = ranked.get(max_idx).unwrap(); + ranked.set(i, vmax); + ranked.set(max_idx, vi); + } + } + + let take = limit.min(ranked.len()); + let mut top = Vec::new(env); + for i in 0..take { + top.push_back(ranked.get(i).unwrap()); + } + top +} diff --git a/contracts/fan-token/src/storage.rs b/contracts/fan-token/src/storage.rs index 4c9d617..0859863 100644 --- a/contracts/fan-token/src/storage.rs +++ b/contracts/fan-token/src/storage.rs @@ -11,6 +11,8 @@ pub enum DataKey { Balance(Address, Address), /// Counter for generating unique token IDs TokenCount, + /// List of holders for an artist token + HolderIndex(Address), } const LIFETIME_THRESHOLD: u32 = 100_000; @@ -64,6 +66,54 @@ pub fn set_balance(env: &Env, artist: &Address, holder: &Address, balance: &FanB .extend_ttl(&key, LIFETIME_THRESHOLD, EXTEND_TO); } +pub fn get_holders(env: &Env, artist: &Address) -> soroban_sdk::Vec
{ + let key = DataKey::HolderIndex(artist.clone()); + let holders = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| soroban_sdk::Vec::new(env)); + if !holders.is_empty() { + env.storage() + .persistent() + .extend_ttl(&key, LIFETIME_THRESHOLD, EXTEND_TO); + } + holders +} + +pub fn sync_holder(env: &Env, artist: &Address, holder: &Address, balance: i128) { + let key = DataKey::HolderIndex(artist.clone()); + let mut holders: soroban_sdk::Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| soroban_sdk::Vec::new(env)); + + let mut idx: Option = None; + for i in 0..holders.len() { + if holders.get(i).unwrap() == *holder { + idx = Some(i); + break; + } + } + + if balance > 0 { + if idx.is_none() { + holders.push_back(holder.clone()); + } + } else if let Some(i) = idx { + let last = holders.pop_back().unwrap(); + if i < holders.len() { + holders.set(i, last); + } + } + + env.storage().persistent().set(&key, &holders); + env.storage() + .persistent() + .extend_ttl(&key, LIFETIME_THRESHOLD, EXTEND_TO); +} + // ── Token counter ─────────────────────────────────────────────────── pub fn next_token_id(env: &Env) -> String { diff --git a/contracts/fan-token/src/test.rs b/contracts/fan-token/src/test.rs index 945b95d..e03001f 100644 --- a/contracts/fan-token/src/test.rs +++ b/contracts/fan-token/src/test.rs @@ -776,3 +776,50 @@ fn test_set_cap_emits_event() { let events_after = env.events().all().len(); assert!(events_after > events_before); } + +#[test] +fn test_list_holders_and_pagination() { + let (env, client, artist, fan1) = setup(); + let fan2 = Address::generate(&env); + let fan3 = Address::generate(&env); + + client.create_fan_token(&artist, &str(&env, "Coin"), &str(&env, "C"), &0, &0); + client.mint_for_tip(&artist, &artist, &fan1, &10); + client.mint_for_tip(&artist, &artist, &fan2, &20); + client.mint_for_tip(&artist, &artist, &fan3, &30); + + let page0 = client.list_holders(&artist, &0, &2); + let page1 = client.list_holders(&artist, &1, &2); + + assert_eq!(page0.len(), 2); + assert_eq!(page1.len(), 1); +} + +#[test] +fn test_top_fans_ranking_and_transfer_maintenance() { + let (env, client, artist, fan1) = setup(); + let fan2 = Address::generate(&env); + let fan3 = Address::generate(&env); + + client.create_fan_token(&artist, &str(&env, "Coin"), &str(&env, "C"), &0, &0); + + client.mint_for_tip(&artist, &artist, &fan1, &10); // 100 + client.mint_for_tip(&artist, &artist, &fan2, &40); // 400 + client.mint_for_tip(&artist, &artist, &fan3, &20); // 200 + + let top = client.get_top_fans(&artist, &2); + assert_eq!(top.len(), 2); + assert_eq!(top.get(0).unwrap().holder, fan2); + assert_eq!(top.get(0).unwrap().balance, 400); + assert_eq!(top.get(1).unwrap().holder, fan3); + assert_eq!(top.get(1).unwrap().balance, 200); + + client.transfer_fan_tokens(&fan2, &fan1, &artist, &400); // fan2 now zero + let holders = client.list_holders(&artist, &0, &10); + for holder in holders.iter() { + assert_ne!(holder, fan2); + } + + let top_after = client.get_top_fans(&artist, &2); + assert_eq!(top_after.get(0).unwrap().holder, fan1); +} diff --git a/contracts/tip-time-lock/src/events.rs b/contracts/tip-time-lock/src/events.rs new file mode 100644 index 0000000..cc64f23 --- /dev/null +++ b/contracts/tip-time-lock/src/events.rs @@ -0,0 +1,69 @@ +use soroban_sdk::{contracttype, symbol_short, Address, Env, String, Symbol}; + +use crate::types::TimeLockTip; + +pub const TOPIC_GROUP: Symbol = symbol_short!("TIP"); +pub const TOPIC_CREATE: Symbol = symbol_short!("CREATE"); +pub const TOPIC_CLAIM: Symbol = symbol_short!("CLAIM"); +pub const TOPIC_REFUND: Symbol = symbol_short!("REFUND"); + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TipActionEvent { + pub action: String, + pub tip_id: String, + pub tipper: Address, + pub artist: Address, + pub amount: i128, + pub required_sigs: Option, + pub approvals: Option, + pub operator: Address, + pub status: String, + pub expires_at: Option, + pub timestamp: u64, +} + +impl TipActionEvent { + fn from_tip( + env: &Env, + action: &str, + status: &str, + tip: &TimeLockTip, + operator: &Address, + ) -> TipActionEvent { + TipActionEvent { + action: String::from_str(env, action), + tip_id: tip.lock_id.clone(), + tipper: tip.tipper.clone(), + artist: tip.artist.clone(), + amount: tip.amount, + required_sigs: None, + approvals: None, + operator: operator.clone(), + status: String::from_str(env, status), + expires_at: Some(tip.unlock_time), + timestamp: env.ledger().timestamp(), + } + } +} + +pub fn emit_tip_created(env: &Env, tip: &TimeLockTip) { + env.events().publish( + (TOPIC_GROUP, TOPIC_CREATE), + TipActionEvent::from_tip(env, "CREATE", "LOCKED", tip, &tip.tipper), + ); +} + +pub fn emit_tip_claimed(env: &Env, tip: &TimeLockTip, operator: &Address) { + env.events().publish( + (TOPIC_GROUP, TOPIC_CLAIM), + TipActionEvent::from_tip(env, "CLAIM", "CLAIMED", tip, operator), + ); +} + +pub fn emit_tip_refunded(env: &Env, tip: &TimeLockTip, operator: &Address) { + env.events().publish( + (TOPIC_GROUP, TOPIC_REFUND), + TipActionEvent::from_tip(env, "REFUND", "REFUNDED", tip, operator), + ); +} diff --git a/contracts/tip-time-lock/src/lib.rs b/contracts/tip-time-lock/src/lib.rs index a65d856..db20581 100644 --- a/contracts/tip-time-lock/src/lib.rs +++ b/contracts/tip-time-lock/src/lib.rs @@ -5,61 +5,15 @@ mod queries; mod rent; mod storage; mod types; +mod events; #[cfg(test)] mod test; -use soroban_sdk::{ - contract, contractimpl, contracttype, symbol_short, token, Address, Env, String, Vec, -}; +use soroban_sdk::{contract, contractimpl, token, Address, Env, String, Vec}; +use events::{emit_tip_claimed, emit_tip_created, emit_tip_refunded}; use types::{Asset, Error, TimeLockStatus, TimeLockTip}; -#[contracttype] -#[derive(Clone, Debug, PartialEq)] -pub struct TipActionEvent { - pub action: String, - pub tip_id: String, - pub tipper: Address, - pub artist: Address, - pub amount: i128, - pub required_sigs: Option, - pub approvals: Option, - pub operator: Address, - pub status: String, - pub expires_at: Option, - pub timestamp: u64, -} - -impl TipActionEvent { - fn new( - env: &Env, - action: &str, - tip_id: String, - tipper: Address, - artist: Address, - amount: i128, - required_sigs: Option, - approvals: Option, - operator: Address, - status: &str, - expires_at: Option, - ) -> TipActionEvent { - TipActionEvent { - action: String::from_str(env, action), - tip_id, - tipper, - artist, - amount, - required_sigs, - approvals, - operator, - status: String::from_str(env, status), - expires_at, - timestamp: env.ledger().timestamp(), - } - } -} - #[contract] pub struct TimeLockContract; @@ -131,22 +85,7 @@ impl TimeLockContract { storage::save_tip(&env, lock_id.clone(), &tip); // Emit canonical tip action event - env.events().publish( - (symbol_short!("TIP"), symbol_short!("CREATE")), - TipActionEvent::new( - &env, - "CREATE", - lock_id.clone(), - tip.tipper.clone(), - tip.artist.clone(), - tip.amount, - None, - None, - tip.tipper.clone(), - "LOCKED", - Some(tip.unlock_time), - ), - ); + emit_tip_created(&env, &tip); Ok(lock_id) } @@ -192,23 +131,8 @@ impl TimeLockContract { } } - // Emit canonical tip action event for claim (execute) - env.events().publish( - (symbol_short!("TIP"), symbol_short!("EXECUTE")), - TipActionEvent::new( - &env, - "EXECUTE", - tip.lock_id.clone(), - tip.tipper.clone(), - tip.artist.clone(), - tip.amount, - None, - None, - artist.clone(), - "CLAIMED", - Some(tip.unlock_time), - ), - ); + // Emit canonical tip action event for claim + emit_tip_claimed(&env, &tip, &artist); Ok(tip.amount) } @@ -251,23 +175,8 @@ impl TimeLockContract { } } - // Emit canonical tip action event for refund (cancel) - env.events().publish( - (symbol_short!("TIP"), symbol_short!("CANCEL")), - TipActionEvent::new( - &env, - "CANCEL", - tip.lock_id.clone(), - tip.tipper.clone(), - tip.artist.clone(), - tip.amount, - None, - None, - tipper.clone(), - "REFUNDED", - Some(tip.unlock_time), - ), - ); + // Emit canonical tip action event for refund + emit_tip_refunded(&env, &tip, &tipper); Ok(()) } diff --git a/contracts/tip-time-lock/src/test.rs b/contracts/tip-time-lock/src/test.rs index 32dc36a..7e1f389 100644 --- a/contracts/tip-time-lock/src/test.rs +++ b/contracts/tip-time-lock/src/test.rs @@ -27,6 +27,16 @@ fn create_token_contract<'a>( ) } +fn assert_event_snapshot_contains(env: &Env, expected: &[&str]) { + let snapshot = format!("{:?}", env.events().all()); + for needle in expected { + assert!( + snapshot.contains(needle), + "event snapshot missing `{needle}`: {snapshot}" + ); + } +} + #[test] fn test_tip_lifecycle() { let env = Env::default(); @@ -59,6 +69,8 @@ fn test_tip_lifecycle() { &1, ); + assert_event_snapshot_contains(&env, &["TIP", "CREATE", "LOCKED", "action", "tip_id"]); + // Check balance assert_eq!(token.balance(&tipper), 900); assert_eq!(token.balance(&contract_id), 100); @@ -73,6 +85,8 @@ fn test_tip_lifecycle() { // Claim client.claim_tip(&lock_id, &artist, &2); + assert_event_snapshot_contains(&env, &["TIP", "CLAIM", "CLAIMED", "operator"]); + // Check balance assert_eq!(token.balance(&artist), 100); assert_eq!(token.balance(&contract_id), 0); @@ -126,6 +140,8 @@ fn test_refund_lifecycle() { // Refund client.refund_tip(&lock_id, &tipper, &3); + assert_event_snapshot_contains(&env, &["TIP", "REFUND", "REFUNDED", "expires_at"]); + // Check balances assert_eq!(token.balance(&tipper), 1000); assert_eq!(token.balance(&contract_id), 0);