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);