diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs new file mode 100644 index 0000000..990377c --- /dev/null +++ b/creator-keys/src/events.rs @@ -0,0 +1,13 @@ +//! Centralized event names and helpers for consistent event emission. +//! +//! This module provides a single source of truth for event names used throughout +//! the contract, reducing string duplication and ensuring consistency across +//! event emission paths. + +use soroban_sdk::{symbol_short, Symbol}; + +/// Event name for creator registration. +pub const REGISTER_EVENT_NAME: Symbol = symbol_short!("register"); + +/// Event name for key purchase. +pub const BUY_EVENT_NAME: Symbol = symbol_short!("buy"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 98fd9aa..ee30bff 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, -}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String}; + +pub mod events; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -103,6 +103,20 @@ pub struct CreatorFeeView { pub is_configured: bool, } +/// Stable, non-optional view of a holder's key count for a creator. +/// +/// Returned by [`CreatorKeysContract::get_holder_key_count`] for indexer-friendly consumption. +/// When `creator_exists` is `false`, the creator is not registered and `key_count` is `0`. +/// When `creator_exists` is `true` but the holder has no keys, `key_count` is `0`. +#[derive(Clone)] +#[contracttype] +pub struct HolderKeyCountView { + pub creator: Address, + pub holder: Address, + pub key_count: u32, + pub creator_exists: bool, +} + /// Stable protocol state version for read-only consumers. /// /// Bump this value only when externally consumed protocol state semantics change. @@ -171,7 +185,7 @@ impl CreatorKeysContract { }; env.storage().persistent().set(&key, &profile); - env.events().publish((symbol_short!("register"),), key); + env.events().publish((events::REGISTER_EVENT_NAME,), key); Ok(()) } @@ -217,7 +231,7 @@ impl CreatorKeysContract { env.storage().persistent().set(&balance_key, &new_balance); env.events().publish( - (symbol_short!("buy"), creator, buyer), + (events::BUY_EVENT_NAME, creator, buyer), (profile.supply, payment), ); @@ -229,6 +243,29 @@ impl CreatorKeysContract { env.storage().persistent().get(&key).unwrap_or(0) } + /// Read-only view: returns a stable view of a holder's key count for a creator. + /// + /// Returns a [`HolderKeyCountView`] regardless of creator registration status. + /// When the creator is not registered, `creator_exists` is `false` and `key_count` is `0`. + /// When the creator exists but the holder has no keys, `key_count` is `0`. + /// This method is designed for indexer-friendly consumption and avoids panics. + pub fn get_holder_key_count(env: Env, creator: Address, holder: Address) -> HolderKeyCountView { + let creator_exists = read_creator_profile(&env, &creator).is_some(); + let key_count = if creator_exists { + let key = DataKey::KeyBalance(creator.clone(), holder.clone()); + env.storage().persistent().get(&key).unwrap_or(0) + } else { + 0 + }; + + HolderKeyCountView { + creator, + holder, + key_count, + creator_exists, + } + } + pub fn get_creator(env: Env, creator: Address) -> Result { read_creator_profile(&env, &creator).ok_or(ContractError::NotRegistered) } diff --git a/creator-keys/tests/buy_key_event.rs b/creator-keys/tests/buy_key_event.rs index 5ce2558..11dee1d 100644 --- a/creator-keys/tests/buy_key_event.rs +++ b/creator-keys/tests/buy_key_event.rs @@ -1,7 +1,7 @@ //! Tests verifying that the buy-key event includes the payment amount. -use creator_keys::{CreatorKeysContract, CreatorKeysContractClient}; -use soroban_sdk::{symbol_short, testutils::Address as _, testutils::Events, Env, IntoVal, String}; +use creator_keys::{events, CreatorKeysContract, CreatorKeysContractClient}; +use soroban_sdk::{testutils::Address as _, testutils::Events, Env, IntoVal, String}; #[test] fn test_buy_key_event_includes_payment_amount() { @@ -53,7 +53,7 @@ fn test_buy_key_event_topics_include_creator_and_buyer() { let topic_creator: soroban_sdk::Address = buy_event.1.get(1).unwrap().into_val(&env); let topic_buyer: soroban_sdk::Address = buy_event.1.get(2).unwrap().into_val(&env); - assert_eq!(topic_symbol, symbol_short!("buy")); + assert_eq!(topic_symbol, events::BUY_EVENT_NAME); assert_eq!(topic_creator, creator); assert_eq!(topic_buyer, buyer); } diff --git a/creator-keys/tests/events.rs b/creator-keys/tests/events.rs index 4f12f0d..e9d119c 100644 --- a/creator-keys/tests/events.rs +++ b/creator-keys/tests/events.rs @@ -1,8 +1,7 @@ //! Tests for registration and buy event payloads (#22). -use creator_keys::{CreatorKeysContract, CreatorKeysContractClient}; +use creator_keys::{events, CreatorKeysContract, CreatorKeysContractClient}; use soroban_sdk::{ - symbol_short, testutils::{Address as _, Events}, Address, Env, IntoVal, String, }; @@ -36,7 +35,7 @@ fn test_register_creator_emits_event() { // First topic should be the "register" symbol let topic: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); - assert_eq!(topic, symbol_short!("register")); + assert_eq!(topic, events::REGISTER_EVENT_NAME); } #[test] @@ -73,9 +72,9 @@ fn test_buy_key_emits_event_with_correct_topics() { let last = events.last().unwrap(); let (_, topics, _) = last; - // Topics: (symbol_short!("buy"), creator, buyer) + // Topics: (events::BUY_EVENT_NAME, creator, buyer) let event_sym: soroban_sdk::Symbol = topics.get(0).unwrap().into_val(&env); - assert_eq!(event_sym, symbol_short!("buy")); + assert_eq!(event_sym, events::BUY_EVENT_NAME); let event_creator: Address = topics.get(1).unwrap().into_val(&env); assert_eq!(event_creator, creator); @@ -130,7 +129,7 @@ fn test_buy_key_event_present_after_purchase() { let has_buy_event = events.iter().any(|(_, topics, _)| { if let Some(v) = topics.get(0) { let sym: soroban_sdk::Symbol = v.into_val(&env); - sym == symbol_short!("buy") + sym == events::BUY_EVENT_NAME } else { false } diff --git a/creator-keys/tests/holder_key_count_view.rs b/creator-keys/tests/holder_key_count_view.rs new file mode 100644 index 0000000..e1387b0 --- /dev/null +++ b/creator-keys/tests/holder_key_count_view.rs @@ -0,0 +1,230 @@ +//! Tests for the `get_holder_key_count` read-only view method. + +use creator_keys::{CreatorKeysContract, CreatorKeysContractClient}; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +fn setup_with_creator(env: &Env) -> (CreatorKeysContractClient<'_>, Address, Address) { + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let creator = Address::generate(env); + client.set_key_price(&admin, &100i128); + client.register_creator(&creator, &String::from_str(env, "test")); + (client, creator, admin) +} + +#[test] +fn test_holder_key_count_view_starts_at_zero() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder = Address::generate(&env); + + let view = client.get_holder_key_count(&creator, &holder); + assert_eq!(view.key_count, 0); + assert!(view.creator_exists); + assert_eq!(view.creator, creator); + assert_eq!(view.holder, holder); +} + +#[test] +fn test_holder_key_count_view_increments_on_buy() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder = Address::generate(&env); + + // Initial state: zero keys + let view = client.get_holder_key_count(&creator, &holder); + assert_eq!(view.key_count, 0); + + // First purchase + client.buy_key(&creator, &holder, &100i128); + let view = client.get_holder_key_count(&creator, &holder); + assert_eq!(view.key_count, 1); + assert!(view.creator_exists); + + // Second purchase + client.buy_key(&creator, &holder, &100i128); + let view = client.get_holder_key_count(&creator, &holder); + assert_eq!(view.key_count, 2); +} + +#[test] +fn test_holder_key_count_view_multiple_holders() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder_a = Address::generate(&env); + let holder_b = Address::generate(&env); + + // Holder A buys 3 keys + client.buy_key(&creator, &holder_a, &100i128); + client.buy_key(&creator, &holder_a, &100i128); + client.buy_key(&creator, &holder_a, &100i128); + + // Holder B buys 1 key + client.buy_key(&creator, &holder_b, &100i128); + + // Verify holder A has 3 keys + let view_a = client.get_holder_key_count(&creator, &holder_a); + assert_eq!(view_a.key_count, 3); + assert!(view_a.creator_exists); + + // Verify holder B has 1 key + let view_b = client.get_holder_key_count(&creator, &holder_b); + assert_eq!(view_b.key_count, 1); + assert!(view_b.creator_exists); +} + +#[test] +fn test_holder_key_count_view_unregistered_creator() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let unregistered_creator = Address::generate(&env); + let holder = Address::generate(&env); + + let view = client.get_holder_key_count(&unregistered_creator, &holder); + assert_eq!(view.key_count, 0); + assert!(!view.creator_exists); + assert_eq!(view.creator, unregistered_creator); + assert_eq!(view.holder, holder); +} + +#[test] +fn test_holder_key_count_view_registered_creator_unseen_wallet() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder_with_keys = Address::generate(&env); + let unseen_wallet = Address::generate(&env); + + // Holder with keys buys some keys + client.buy_key(&creator, &holder_with_keys, &100i128); + client.buy_key(&creator, &holder_with_keys, &100i128); + + // Unseen wallet should have zero keys + let view = client.get_holder_key_count(&creator, &unseen_wallet); + assert_eq!(view.key_count, 0); + assert!(view.creator_exists); +} + +#[test] +fn test_holder_key_count_view_consistency_with_get_key_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder = Address::generate(&env); + + // Buy some keys + client.buy_key(&creator, &holder, &100i128); + client.buy_key(&creator, &holder, &100i128); + client.buy_key(&creator, &holder, &100i128); + + // Both methods should return the same key count + let view = client.get_holder_key_count(&creator, &holder); + let balance = client.get_key_balance(&creator, &holder); + assert_eq!(view.key_count, balance); +} + +#[test] +fn test_holder_key_count_view_no_state_mutation() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder = Address::generate(&env); + + // Buy some keys first + client.buy_key(&creator, &holder, &100i128); + client.buy_key(&creator, &holder, &100i128); + + // Multiple reads should return the same result (no mutation) + let view1 = client.get_holder_key_count(&creator, &holder); + let view2 = client.get_holder_key_count(&creator, &holder); + let view3 = client.get_holder_key_count(&creator, &holder); + + assert_eq!(view1.key_count, view2.key_count); + assert_eq!(view2.key_count, view3.key_count); + assert_eq!(view1.key_count, 2); +} + +#[test] +fn test_holder_key_count_view_zero_keys_different_creators() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator_a = Address::generate(&env); + let creator_b = Address::generate(&env); + let holder = Address::generate(&env); + + client.set_key_price(&admin, &100i128); + client.register_creator(&creator_a, &String::from_str(&env, "alice")); + client.register_creator(&creator_b, &String::from_str(&env, "bob")); + + // Holder buys keys only from creator A + client.buy_key(&creator_a, &holder, &100i128); + client.buy_key(&creator_a, &holder, &100i128); + + // Check holder has 0 keys for creator B + let view_b = client.get_holder_key_count(&creator_b, &holder); + assert_eq!(view_b.key_count, 0); + assert!(view_b.creator_exists); + + // Check holder has 2 keys for creator A + let view_a = client.get_holder_key_count(&creator_a, &holder); + assert_eq!(view_a.key_count, 2); + assert!(view_a.creator_exists); +} + +#[test] +fn test_holder_key_count_view_structure_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, creator, _admin) = setup_with_creator(&env); + let holder = Address::generate(&env); + + client.buy_key(&creator, &holder, &100i128); + + let view = client.get_holder_key_count(&creator, &holder); + + // Verify all fields are populated correctly + assert_eq!(view.creator, creator); + assert_eq!(view.holder, holder); + assert_eq!(view.key_count, 1); + assert!(view.creator_exists); +} + +#[test] +fn test_holder_key_count_view_unregistered_creator_fields() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let unregistered_creator = Address::generate(&env); + let holder = Address::generate(&env); + + let view = client.get_holder_key_count(&unregistered_creator, &holder); + + // Verify all fields are populated correctly for unregistered creator + assert_eq!(view.creator, unregistered_creator); + assert_eq!(view.holder, holder); + assert_eq!(view.key_count, 0); + assert!(!view.creator_exists); +}