From 863b81d6307f13d3b7bc6616612c1165f4fa606f Mon Sep 17 00:00:00 2001 From: Thompson <140930314+Godbrand0@users.noreply.github.com> Date: Wed, 27 May 2026 03:23:29 -0400 Subject: [PATCH] Implement quest completion event indexing for Stellar indexers - Add CHAIN_STARTED event emitted when a player joins a chain (participation tracking) - Add REWARD_CLAIMED and POOL_FUNDED as named constants (replace inline symbol_short!) - Add chain_id as indexed topic to QUEST_COMPLETED, CHAIN_COMPLETED, PROGRESS_CHECKPOINT, CHAIN_RESET, REWARD_CLAIMED, and POOL_FUNDED events for off-chain analytics - Add event tests covering chain creation, participation, quest completion, chain completion, and reward claiming Closes #205 --- contracts/quest_chain/src/lib.rs | 34 ++++-- contracts/quest_chain/src/test.rs | 197 +++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 16 deletions(-) diff --git a/contracts/quest_chain/src/lib.rs b/contracts/quest_chain/src/lib.rs index d937bd1..15e3f8d 100644 --- a/contracts/quest_chain/src/lib.rs +++ b/contracts/quest_chain/src/lib.rs @@ -116,11 +116,14 @@ const MAX_LEADERBOARD_ENTRIES: u32 = 100; // const CHAIN_CREATED: Symbol = symbol_short!("chain_crt"); +const CHAIN_STARTED: Symbol = symbol_short!("chn_start"); const QUEST_UNLOCKED: Symbol = symbol_short!("qst_unlck"); const QUEST_COMPLETED: Symbol = symbol_short!("qst_done"); const CHAIN_COMPLETED: Symbol = symbol_short!("chn_done"); const PROGRESS_CHECKPOINT: Symbol = symbol_short!("checkpt"); const CHAIN_RESET: Symbol = symbol_short!("chn_reset"); +const REWARD_CLAIMED: Symbol = symbol_short!("rwrd_clmd"); +const POOL_FUNDED: Symbol = symbol_short!("pool_fund"); // // ────────────────────────────────────────────────────────── @@ -303,6 +306,11 @@ impl QuestChainContract { env.storage() .persistent() .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); + + env.events().publish( + (CHAIN_STARTED, player.clone(), chain_id), + (progress.start_time,), + ); } /// Complete a quest in a chain @@ -376,8 +384,8 @@ impl QuestChainContract { if quest.checkpoint { progress.checkpoint_quest = Some(quest_id); env.events().publish( - (PROGRESS_CHECKPOINT, player.clone()), - (chain_id, quest_id), + (PROGRESS_CHECKPOINT, player.clone(), chain_id), + (quest_id,), ); } @@ -404,8 +412,8 @@ impl QuestChainContract { .set(&DataKey::ChainCompletions(chain_id), &completions); env.events().publish( - (CHAIN_COMPLETED, player.clone()), - (chain_id, duration, progress.total_reward_earned), + (CHAIN_COMPLETED, player.clone(), chain_id), + (duration, progress.total_reward_earned), ); } @@ -414,8 +422,8 @@ impl QuestChainContract { .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); env.events().publish( - (QUEST_COMPLETED, player.clone()), - (chain_id, quest_id, quest.reward), + (QUEST_COMPLETED, player.clone(), chain_id), + (quest_id, quest.reward), ); } @@ -503,8 +511,8 @@ impl QuestChainContract { .set(&DataKey::PlayerProgress(player.clone(), chain_id), &progress); env.events().publish( - (CHAIN_RESET, player.clone()), - (chain_id, checkpoint_id), + (CHAIN_RESET, player.clone(), chain_id), + (checkpoint_id,), ); } @@ -539,7 +547,7 @@ impl QuestChainContract { .remove(&DataKey::PendingRewards(player.clone(), chain_id)); } - env.events().publish((CHAIN_RESET, player.clone()), (chain_id, 0u32)); + env.events().publish((CHAIN_RESET, player.clone(), chain_id), (0u32,)); } // ───────────── VIEW FUNCTIONS ───────────── @@ -660,8 +668,8 @@ impl QuestChainContract { .remove(&DataKey::PendingRewards(player.clone(), chain_id)); env.events().publish( - (symbol_short!("rwrd_clmd"), player.clone()), - (chain_id, pending), + (REWARD_CLAIMED, player.clone(), chain_id), + (pending,), ); pending @@ -758,8 +766,8 @@ impl QuestChainContract { .set(&DataKey::RewardPool(chain_id), &(current_pool + amount)); env.events().publish( - (symbol_short!("pool_fund"), admin), - (chain_id, amount, current_pool + amount), + (POOL_FUNDED, admin, chain_id), + (amount, current_pool + amount), ); } diff --git a/contracts/quest_chain/src/test.rs b/contracts/quest_chain/src/test.rs index ca90091..8415c60 100644 --- a/contracts/quest_chain/src/test.rs +++ b/contracts/quest_chain/src/test.rs @@ -2,8 +2,8 @@ use super::*; use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, Symbol, + testutils::{Address as _, Events, Ledger}, + Address, Env, Symbol, TryFromVal, }; fn setup_contract(env: &Env) -> (QuestChainContractClient, Address) { @@ -119,7 +119,7 @@ fn test_double_initialization() { env.mock_all_auths(); let (client, admin) = setup_contract(&env); - client.initialize(&admin); + client.initialize(&admin, &None); } #[test] @@ -811,6 +811,197 @@ fn test_reward_token_configuration() { assert_eq!(config.reward_token, Some(reward_token)); } +// ────────────────────────────────────────────────────────── +// EVENT TESTS +// ────────────────────────────────────────────────────────── + +fn find_event_by_name( + env: &Env, + events: &soroban_sdk::Vec<(Address, soroban_sdk::Vec, soroban_sdk::Val)>, + name: &Symbol, +) -> Option<(Address, soroban_sdk::Vec, soroban_sdk::Val)> { + for event in events.iter() { + let topics = &event.1; + if let Some(first) = topics.get(0) { + if let Ok(sym) = Symbol::try_from_val(env, &first) { + if sym == *name { + return Some(event.clone()); + } + } + } + } + None +} + +#[test] +fn test_event_chain_created() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "TestChain"), + &Symbol::new(&env, "Desc"), + &quests, + &None, + &None, + ); + + let events = env.events().all(); + let event = find_event_by_name(&env, &events, &CHAIN_CREATED); + assert!(event.is_some(), "CHAIN_CREATED event not found"); + let (_, topics, _) = event.unwrap(); + assert_eq!(Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), CHAIN_CREATED); + assert_eq!(u32::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), chain_id); +} + +#[test] +fn test_event_chain_started() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "TestChain"), + &Symbol::new(&env, "Desc"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + + let events = env.events().all(); + let event = find_event_by_name(&env, &events, &CHAIN_STARTED); + assert!(event.is_some(), "CHAIN_STARTED event not found"); + let (_, topics, _) = event.unwrap(); + assert_eq!(Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), CHAIN_STARTED); + assert_eq!(Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), player); + assert_eq!(u32::try_from_val(&env, &topics.get(2).unwrap()).unwrap(), chain_id); +} + +#[test] +fn test_event_quest_completed() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "TestChain"), + &Symbol::new(&env, "Desc"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.complete_quest(&player, &chain_id, &1); + + let events = env.events().all(); + let event = find_event_by_name(&env, &events, &QUEST_COMPLETED); + assert!(event.is_some(), "QUEST_COMPLETED event not found"); + let (_, topics, _) = event.unwrap(); + assert_eq!(Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), QUEST_COMPLETED); + assert_eq!(Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), player); + assert_eq!(u32::try_from_val(&env, &topics.get(2).unwrap()).unwrap(), chain_id); +} + +#[test] +fn test_event_chain_completed() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let (client, admin) = setup_contract(&env); + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "TestChain"), + &Symbol::new(&env, "Desc"), + &quests, + &None, + &None, + ); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.complete_quest(&player, &chain_id, &1); + client.complete_quest(&player, &chain_id, &2); + client.complete_quest(&player, &chain_id, &3); + client.complete_quest(&player, &chain_id, &4); + env.ledger().set_timestamp(2000); + client.complete_quest(&player, &chain_id, &5); + + let events = env.events().all(); + let event = find_event_by_name(&env, &events, &CHAIN_COMPLETED); + assert!(event.is_some(), "CHAIN_COMPLETED event not found"); + let (_, topics, _) = event.unwrap(); + assert_eq!(Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), CHAIN_COMPLETED); + assert_eq!(Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), player); + assert_eq!(u32::try_from_val(&env, &topics.get(2).unwrap()).unwrap(), chain_id); +} + +#[test] +fn test_event_reward_claimed() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1000); + + let admin = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + let reward_token = sac.address(); + let sac_client = soroban_sdk::token::StellarAssetClient::new(&env, &reward_token); + + let contract_id = env.register_contract(None, QuestChainContract); + let client = QuestChainContractClient::new(&env, &contract_id); + client.initialize(&admin, &Some(reward_token.clone())); + + let quests = create_test_quests(&env); + let chain_id = client.create_chain( + &admin, + &Symbol::new(&env, "TestChain"), + &Symbol::new(&env, "Desc"), + &quests, + &None, + &None, + ); + + // Mint tokens to the quest chain contract so it can pay out rewards + sac_client.mint(&contract_id, &10000i128); + + // Seed reward pool directly in storage + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&DataKey::RewardPool(chain_id), &10000i128); + }); + + let player = Address::generate(&env); + client.start_chain(&player, &chain_id); + client.complete_quest(&player, &chain_id, &1); + client.claim_rewards(&player, &chain_id); + + let events = env.events().all(); + let event = find_event_by_name(&env, &events, &REWARD_CLAIMED); + assert!(event.is_some(), "REWARD_CLAIMED event not found"); + let (_, topics, _) = event.unwrap(); + assert_eq!(Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(), REWARD_CLAIMED); + assert_eq!(Address::try_from_val(&env, &topics.get(1).unwrap()).unwrap(), player); + assert_eq!(u32::try_from_val(&env, &topics.get(2).unwrap()).unwrap(), chain_id); +} + #[test] fn test_pending_rewards_tracking() { let env = Env::default();