diff --git a/.github/workflows/contract-ci.yml b/.github/workflows/contract-ci.yml index 854b6ac6c..18fe3e9fb 100644 --- a/.github/workflows/contract-ci.yml +++ b/.github/workflows/contract-ci.yml @@ -68,10 +68,13 @@ jobs: - name: Clippy lint run: cargo clippy --all-targets --all-features -- -D warnings + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + - name: License check - run: | - cargo install cargo-deny || true - make license-check + run: make license-check - name: Build WASM run: cargo build --release --target wasm32-unknown-unknown diff --git a/contracts/earn-quest/src/escrow.rs b/contracts/earn-quest/src/escrow.rs index 8d3082d6f..baa96d3e7 100644 --- a/contracts/earn-quest/src/escrow.rs +++ b/contracts/earn-quest/src/escrow.rs @@ -17,7 +17,10 @@ use crate::types::{EscrowBalances, EscrowInfo, EscrowMeta, QuestStatus, Verifier use crate::validation; fn available_balance(balances: &EscrowBalances) -> i128 { - balances.total_deposited - balances.total_paid_out - balances.total_refunded + balances + .total_deposited + .saturating_sub(balances.total_paid_out) + .saturating_sub(balances.total_refunded) } fn require_active_escrow(balances: &EscrowBalances) -> Result<(), Error> { @@ -78,7 +81,8 @@ pub fn deposit( // transaction reverts and the storage write is rolled back, but a // re-entrant call during the transfer will see a fully-updated record // and cannot inflate the deposit total a second time. - let mut balances = if storage::has_escrow(env, quest_id) { + let is_top_up = storage::has_escrow(env, quest_id); + let mut balances = if is_top_up { let existing = storage::get_escrow_balances(env, quest_id)?; require_active_escrow(&existing)?; existing @@ -102,19 +106,34 @@ pub fn deposit( } }; - balances.total_deposited += amount; - balances.deposit_count += 1; + balances.total_deposited = balances.total_deposited.saturating_add(amount); + balances.deposit_count = balances.deposit_count.saturating_add(1); storage::set_escrow_balances(env, quest_id, &balances); let available = available_balance(&balances); - events::escrow_deposited( - env, - quest_id.clone(), - depositor.clone(), - token_address.clone(), - amount, - available, - ); + + if is_top_up { + // Subsequent deposit: emit dedicated top-up event so indexers and the + // backend can detect funding changes without polling storage. + events::escrow_topped_up( + env, + quest_id.clone(), + depositor.clone(), + token_address.clone(), + amount, + available, + ); + } else { + // Initial deposit: emit the standard deposit event. + events::escrow_deposited( + env, + quest_id.clone(), + depositor.clone(), + token_address.clone(), + amount, + available, + ); + } // Transfer tokens: creator → contract (external call, kept last) let token_client = token::Client::new(env, token_address); @@ -182,7 +201,7 @@ pub fn record_payout( return Err(Error::InsufficientEscrow); } - b.total_paid_out += amount; + b.total_paid_out = b.total_paid_out.saturating_add(amount); storage::set_escrow_balances(env, quest_id, &b); let remaining = available_balance(&b); @@ -208,7 +227,6 @@ fn refund_remaining(env: &Env, quest_id: &Symbol) -> Result { let mut b = storage::get_escrow_balances(env, quest_id)?; let meta = storage::get_escrow_meta(env, quest_id)?; - let _available = b.total_deposited - b.total_paid_out - b.total_refunded; let available = available_balance(&b); let depositor = meta.depositor.clone(); let token = meta.token.clone(); @@ -217,7 +235,7 @@ fn refund_remaining(env: &Env, quest_id: &Symbol) -> Result { // re-entrant call during the transfer below cannot trigger a second // refund (it would see is_active=false). On transfer failure the // transaction reverts and the storage write is rolled back atomically. - b.total_refunded += available; + b.total_refunded = b.total_refunded.saturating_add(available); b.is_active = false; storage::set_escrow_balances(env, quest_id, &b); @@ -483,8 +501,11 @@ pub fn slash_verifier_stake( return Err(Error::VerifierStakeInactive); } - let slash_amount = (stake.amount * slash_bps as u128) / 10_000; - let remainder = stake.amount - slash_amount; + let slash_amount = stake + .amount + .saturating_mul(slash_bps as u128) + .saturating_div(10_000); + let remainder = stake.amount.saturating_sub(slash_amount); stake.amount = 0; stake.is_active = false; diff --git a/contracts/earn-quest/src/events.rs b/contracts/earn-quest/src/events.rs index 2ae4118a7..acc3b1f5e 100644 --- a/contracts/earn-quest/src/events.rs +++ b/contracts/earn-quest/src/events.rs @@ -27,6 +27,7 @@ const TOPIC_DISPUTE_RESOLVED: Symbol = symbol_short!("disp_res"); const TOPIC_DISPUTE_WITHDRAWN: Symbol = symbol_short!("disp_wd"); const TOPIC_DISPUTE_APPEALED: Symbol = symbol_short!("disp_appl"); const TOPIC_ESCROW_DEPOSITED: Symbol = symbol_short!("esc_dep"); +const TOPIC_ESCROW_TOPPED_UP: Symbol = symbol_short!("esc_top"); const TOPIC_ESCROW_PAYOUT: Symbol = symbol_short!("esc_pay"); const TOPIC_ESCROW_REFUNDED: Symbol = symbol_short!("esc_ref"); const TOPIC_COMMITMENT_SUBMITTED: Symbol = symbol_short!("com_sub"); @@ -302,6 +303,40 @@ pub fn escrow_deposited( env.events().publish(topics, data); } +/// Emit when tokens are added to an existing escrow (top-up) (indexed: quest_id, depositor). +/// +/// Emitted on every deposit after the initial one, allowing indexers and the backend +/// to distinguish top-ups from the first funding event without polling storage. +/// +/// # Event Schema +/// Topics: [EventName, QuestID, Depositor, Token] — `quest_id` is the primary +/// indexed topic for efficient filtering by quest. +/// Data: (amount: i128, new_balance: i128) +/// +/// # Indexing Benefits +/// * Filter all top-ups for a specific quest by `quest_id` topic +/// * Track how many times a quest was re-funded and by how much +/// * Backend / indexers can react to top-ups without polling storage +pub fn escrow_topped_up( + env: &Env, + quest_id: Symbol, + depositor: Address, + token: Address, + amount: i128, + new_balance: i128, +) { + // Topics: [EventName, QuestID, Depositor, Token] — quest_id indexed for efficient filtering + let topics = ( + TOPIC_ESCROW_TOPPED_UP, + quest_id, + depositor.clone(), + token.clone(), + ); + // Data: top-up amount and resulting available balance + let data = (amount, new_balance); + env.events().publish(topics, data); +} + /// Emit when tokens are paid out from escrow (indexed: quest_id, recipient). /// /// # Indexing Benefits diff --git a/contracts/earn-quest/src/lib.rs b/contracts/earn-quest/src/lib.rs index 41d8be038..1d8b54972 100644 --- a/contracts/earn-quest/src/lib.rs +++ b/contracts/earn-quest/src/lib.rs @@ -24,6 +24,9 @@ mod test_token; #[cfg(test)] mod test_clawback; +#[cfg(test)] +mod test_stats; + use crate::errors::Error; use crate::storage::{get_badge_type, list_badge_types}; @@ -525,7 +528,7 @@ impl EarnQuestContract { // transfer. If a malicious token re-enters during the transfer the // AlreadyClaimed check in validate_claim_data rejects the second call. let mut submission = submission; - submission.claimed_amount += amount; + submission.claimed_amount = submission.claimed_amount.saturating_add(amount); submission.status = if submission.claimed_amount == quest.reward_amount { types::SubmissionStatus::Paid } else { @@ -535,7 +538,7 @@ impl EarnQuestContract { // Increment claims: directly update quest to avoid extra read let mut quest = quest; - quest.total_claims += 1; + quest.total_claims = quest.total_claims.saturating_add(1); storage::set_quest(&env, &quest_id, &quest); payout::transfer_reward_from_escrow( @@ -550,12 +553,19 @@ impl EarnQuestContract { &env, quest_id.clone(), submitter.clone(), - quest.reward_asset, + quest.reward_asset.clone(), amount, ); reputation::award_xp(&env, &submitter, 100)?; + // Update platform and creator stats + storage::inc_platform_rewards_claimed(&env); + let creator = quest.creator.clone(); + let mut creator_stats = storage::get_creator_stats(&env, &creator); + creator_stats.total_claims_paid = creator_stats.total_claims_paid.saturating_add(1); + storage::set_creator_stats(&env, &creator, &creator_stats); + security::nonreentrant_exit(&env); Ok(()) } diff --git a/contracts/earn-quest/src/oracle.rs b/contracts/earn-quest/src/oracle.rs index a293d737e..9fed20a37 100644 --- a/contracts/earn-quest/src/oracle.rs +++ b/contracts/earn-quest/src/oracle.rs @@ -45,7 +45,7 @@ impl Oracle { if price_data.timestamp > current_time { return Err(Error::InvalidOracleData); } - let age = current_time - price_data.timestamp; + let age = current_time.saturating_sub(price_data.timestamp); if age > oracle_config.max_age_seconds || age > request.max_age_seconds { return Err(Error::StaleOracleData); } @@ -68,10 +68,10 @@ impl Oracle { request: &PriceFeedRequest, ) -> Result { let mut valid_prices: Vec<(PriceData, u32)> = Vec::new(env); - let mut total_sources = 0; + let mut total_sources: u32 = 0; for config in oracle_configs.iter() { - total_sources += 1; + total_sources = total_sources.saturating_add(1); if let Ok(price_data) = Self::get_price(env, &config, request) { valid_prices.push_back((price_data, config.min_confidence)); @@ -165,8 +165,8 @@ impl Oracle { let w = U256::from_u32(env, weight); let weighted_price = price_data.price.mul(&w); weighted_sum = weighted_sum.add(&weighted_price); - total_weight += weight; - confidence_sum += price_data.confidence; + total_weight = total_weight.saturating_add(weight); + confidence_sum = confidence_sum.saturating_add(price_data.confidence); } if total_weight == 0 { @@ -174,7 +174,7 @@ impl Oracle { } let weighted_price = weighted_sum.div(&U256::from_u32(env, total_weight)); - let avg_confidence = confidence_sum / valid_prices.len(); + let avg_confidence = confidence_sum.saturating_div(valid_prices.len()); Ok(AggregatedPrice { base_asset: request.base_asset.clone(), @@ -217,7 +217,7 @@ impl Oracle { // Check if price is not stale let current_time = env.ledger().timestamp(); - if current_time - response.price_data.timestamp > request.max_age_seconds { + if current_time.saturating_sub(response.price_data.timestamp) > request.max_age_seconds { return Err(Error::StaleOracleData); } @@ -242,11 +242,11 @@ impl Oracle { } if from_decimals > to_decimals { - let diff = from_decimals - to_decimals; - Ok(price.div(&U256::from_u32(env, 10u32.pow(diff)))) + let diff = from_decimals.saturating_sub(to_decimals); + Ok(price.div(&U256::from_u32(env, 10u32.saturating_pow(diff)))) } else { - let diff = to_decimals - from_decimals; - Ok(price.mul(&U256::from_u32(env, 10u32.pow(diff)))) + let diff = to_decimals.saturating_sub(from_decimals); + Ok(price.mul(&U256::from_u32(env, 10u32.saturating_pow(diff)))) } } diff --git a/contracts/earn-quest/src/quest.rs b/contracts/earn-quest/src/quest.rs index ffbf327a0..91d17384d 100644 --- a/contracts/earn-quest/src/quest.rs +++ b/contracts/earn-quest/src/quest.rs @@ -138,6 +138,14 @@ pub fn register_quest_with_category_and_grace_period( storage::inc_platform_quests_created(env); storage::add_platform_rewards_distributed(env, reward_amount as u128); + // Update per-creator stats + let mut creator_stats = storage::get_creator_stats(env, creator); + creator_stats.quests_created = creator_stats.quests_created.saturating_add(1); + creator_stats.total_rewards_posted = creator_stats + .total_rewards_posted + .saturating_add(reward_amount); + storage::set_creator_stats(env, creator, &creator_stats); + events::quest_registered( env, id.clone(), @@ -387,9 +395,9 @@ pub fn get_quests_by_status( if &quest.status == status { if matched >= offset { results.push_back(quest); - count += 1; + count = count.saturating_add(1); } - matched += 1; + matched = matched.saturating_add(1); } } } @@ -428,9 +436,9 @@ pub fn get_quests_by_creator(env: &Env, creator: &Address, offset: u32, limit: u if &quest.creator == creator { if matched >= offset { results.push_back(quest); - count += 1; + count = count.saturating_add(1); } - matched += 1; + matched = matched.saturating_add(1); } } } @@ -455,9 +463,9 @@ pub fn get_quests_by_category(env: &Env, category: u32, offset: u32, limit: u32) if quest.category == category { if matched >= offset { results.push_back(quest); - count += 1; + count = count.saturating_add(1); } - matched += 1; + matched = matched.saturating_add(1); } } } @@ -518,9 +526,9 @@ pub fn get_quests_by_reward_range( if quest.reward_amount >= min_reward && quest.reward_amount <= max_reward { if matched >= offset { results.push_back(quest); - count += 1; + count = count.saturating_add(1); } - matched += 1; + matched = matched.saturating_add(1); } } } diff --git a/contracts/earn-quest/src/reputation.rs b/contracts/earn-quest/src/reputation.rs index 488bf2e0f..1187758a9 100644 --- a/contracts/earn-quest/src/reputation.rs +++ b/contracts/earn-quest/src/reputation.rs @@ -27,8 +27,8 @@ const LEVEL_5_XP: u64 = 1500; pub fn award_xp(env: &Env, user: &Address, xp_amount: u64) -> Result { let mut stats = storage::get_user_stats_or_default(env, user); - stats.xp += xp_amount; - stats.quests_completed += 1; + stats.xp = stats.xp.saturating_add(xp_amount); + stats.quests_completed = stats.quests_completed.saturating_add(1); let new_level = calculate_level(stats.xp); let level_up = new_level > stats.level; diff --git a/contracts/earn-quest/src/security.rs b/contracts/earn-quest/src/security.rs index 867ee2165..4b58b6362 100644 --- a/contracts/earn-quest/src/security.rs +++ b/contracts/earn-quest/src/security.rs @@ -118,7 +118,7 @@ pub fn emergency_approve_unpause(env: &Env, caller: &Address) -> Result<(), Erro if approvals >= threshold { let now = env.ledger().timestamp(); let tl = storage::get_unpause_timelock_seconds(env); - let scheduled = now + tl; + let scheduled = now.saturating_add(tl); storage::set_scheduled_unpause_time(env, scheduled); events::timelock_scheduled(env, scheduled); } diff --git a/contracts/earn-quest/src/stats.rs b/contracts/earn-quest/src/stats.rs deleted file mode 100644 index af09ebc60..000000000 --- a/contracts/earn-quest/src/stats.rs +++ /dev/null @@ -1,46 +0,0 @@ -// ============================================================ -// ADD THESE STRUCTS TO contracts/earn-quest/src/types.rs -// ============================================================ -// Paste both structs at the bottom of the existing types.rs -// file. They reuse the existing `#[contracttype]` + `#[derive]` pattern. -// ============================================================ - -use soroban_sdk::contracttype; - -/// Platform-wide aggregated statistics. -/// -/// Updated atomically on every quest creation, submission, and claim. -/// Queried via `EarnQuestContract::get_platform_stats()`. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PlatformStats { - /// Total number of quests ever registered (never decremented). - pub total_quests_created: u64, - /// Total number of proof submissions ever received. - pub total_submissions: u64, - /// Sum of `reward_amount` across all quests ever created (in token base units). - /// Represents total value posted, not necessarily paid out. - pub total_rewards_distributed: u128, - /// Number of unique wallet addresses that have submitted at least once. - pub total_active_users: u64, - /// Number of rewards that reached the `Paid` status (successful claims). - pub total_rewards_claimed: u64, -} - -/// Per-creator statistics, scoped to a single quest creator address. -/// -/// Updated on quest creation and whenever a submission or claim -/// targets a quest owned by this creator. -/// Queried via `EarnQuestContract::get_creator_stats(creator)`. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CreatorStats { - /// Total quests created by this address. - pub quests_created: u64, - /// Sum of `reward_amount` across all quests created by this address. - pub total_rewards_posted: u128, - /// Total submissions received across all of this creator's quests. - pub total_submissions_received: u64, - /// Total successful claims paid out across all of this creator's quests. - pub total_claims_paid: u64, -} \ No newline at end of file diff --git a/contracts/earn-quest/src/storage.rs b/contracts/earn-quest/src/storage.rs index 7e00e7a0d..343f8b0c3 100644 --- a/contracts/earn-quest/src/storage.rs +++ b/contracts/earn-quest/src/storage.rs @@ -472,7 +472,7 @@ pub fn update_quest_status(env: &Env, id: &Symbol, status: QuestStatus) -> Resul /// * Type-safe increment operation pub fn increment_quest_claims(env: &Env, id: &Symbol) -> Result<(), Error> { let mut quest = get_quest(env, id)?; - quest.total_claims += 1; + quest.total_claims = quest.total_claims.saturating_add(1); set_quest(env, id, &quest); Ok(()) } @@ -734,7 +734,7 @@ pub fn remove_oracle_config(env: &Env, oracle_address: &Address) -> Result<(), E addrs.remove(i); break; } - i += 1; + i = i.saturating_add(1); } set_oracle_addresses(env, &addrs); Ok(()) @@ -1194,7 +1194,7 @@ pub fn remove_quest_from_category_index(env: &Env, category: u32, id: &Symbol) { .set(&DataKey::QuestCategory(category), &ids); return; } - i += 1; + i = i.saturating_add(1); } } @@ -1291,6 +1291,18 @@ pub fn inc_platform_rewards_claimed(env: &Env) { .set(&DataKey::PlatformRewardsClaimed, &v.saturating_add(1)); } +/// Increment only the active-users counter. +pub fn inc_platform_active_users(env: &Env) { + let v: u64 = env + .storage() + .instance() + .get(&DataKey::PlatformActiveUsers) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::PlatformActiveUsers, &v.saturating_add(1)); +} + /// Add to the rewards-distributed counter. pub fn add_platform_rewards_distributed(env: &Env, amount: u128) { let v: u128 = env @@ -1447,7 +1459,7 @@ pub fn remove_badge_type_id(env: &Env, id: &Symbol) { env.storage().instance().set(&DataKey::BadgeTypeIds, &ids); return; } - i += 1; + i = i.saturating_add(1); } } @@ -1459,7 +1471,7 @@ pub fn list_badge_types(env: &Env) -> Vec { if let Ok(bt) = get_badge_type(env, &ids.get(i).unwrap()) { out.push_back(bt); } - i += 1; + i = i.saturating_add(1); } out } diff --git a/contracts/earn-quest/src/submission.rs b/contracts/earn-quest/src/submission.rs index a175981d0..e6de346f1 100644 --- a/contracts/earn-quest/src/submission.rs +++ b/contracts/earn-quest/src/submission.rs @@ -1,7 +1,7 @@ use crate::errors::Error; use crate::events; use crate::storage; -use crate::types::{BatchApprovalInput, Commitment, Submission, SubmissionStatus}; +use crate::types::{BatchApprovalInput, Commitment, Submission, SubmissionStatus, UserCore}; use crate::validation; use soroban_sdk::{xdr::ToXdr, Address, Bytes, BytesN, Env, Symbol, Vec}; @@ -154,8 +154,6 @@ pub fn submit_proof( .grace_period_seconds .unwrap_or(storage::get_default_grace_period(env)); validation::validate_quest_not_expired(env, quest.deadline, grace_period_seconds)?; - // Validate submitter address - validation::validate_badge_count(0)?; // Example: badge count check for submitter let submission = Submission { quest_id: quest_id.clone(), @@ -166,8 +164,34 @@ pub fn submit_proof( timestamp: env.ledger().timestamp(), }; + // Track active users: only count first-ever submission by this address. + // We mark the user as "seen" by initialising their stats record so a + // second submit_proof call for the same address won't double-count. + let is_first_submission = !storage::has_user_stats(env, submitter); + if is_first_submission { + // Write a default stats record to mark this user as known. + storage::set_user_stats( + env, + submitter, + &UserCore { + xp: 0, + level: 1, + quests_completed: 0, + }, + ); + } storage::set_submission(env, quest_id, submitter, &submission); + // Platform & creator stats counters + storage::inc_platform_submissions(env); + if is_first_submission { + storage::inc_platform_active_users(env); + } + let mut creator_stats = storage::get_creator_stats(env, &quest.creator); + creator_stats.total_submissions_received = + creator_stats.total_submissions_received.saturating_add(1); + storage::set_creator_stats(env, &quest.creator, &creator_stats); + // EMIT EVENT: ProofSubmitted events::proof_submitted(env, quest_id.clone(), submitter.clone(), proof_hash.clone()); @@ -231,7 +255,9 @@ pub fn validate_claim_amount( ) -> Result { validation::validate_reward_amount(amount)?; - let remaining = quest.reward_amount - submission.claimed_amount; + let remaining = quest + .reward_amount + .saturating_sub(submission.claimed_amount); if amount > remaining { return Err(Error::InvalidRewardAmount); } @@ -384,8 +410,10 @@ pub fn approve_submissions_batch( if !escrow.is_active { return Err(Error::EscrowInactive); } - let available = - escrow.total_deposited - escrow.total_paid_out - escrow.total_refunded; + let available = escrow + .total_deposited + .saturating_sub(escrow.total_paid_out) + .saturating_sub(escrow.total_refunded); if available < quest.reward_amount { return Err(Error::InsufficientEscrow); } diff --git a/contracts/earn-quest/src/test_arbitrator.rs b/contracts/earn-quest/src/test_arbitrator.rs deleted file mode 100644 index a13f8125a..000000000 --- a/contracts/earn-quest/src/test_arbitrator.rs +++ /dev/null @@ -1,80 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{symbol_short, Address, Env}; - -use earn_quest::{EarnQuestContractClient, Dispute, DisputeStatus}; - -fn make_env() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env -} - -fn set_time(env: &Env, ts: u64) { - env.ledger().set(LedgerInfo { - protocol_version: 20, - sequence_number: 1, - timestamp: ts, - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 100, - min_persistent_entry_ttl: 100, - max_entry_ttl: 1_000_000, - }); -} - -fn setup(env: &Env) -> (EarnQuestContractClient, Address) { - let cid = env.register_contract(None, earn_quest::EarnQuestContract); - let client = EarnQuestContractClient::new(env, &cid); - let admin = Address::generate(env); - client.initialize(&admin); - (client, admin) -} - -#[test] -fn test_arbitrator_rotation_timelock_and_authority() { - let env = make_env(); - set_time(&env, 1_000); - let (client, admin) = setup(&env); - - // Set initial arbitrator A0 - let a0 = Address::generate(&env); - client.schedule_arbitrator_change(&admin, &a0).unwrap(); - // Before timelock finalize should fail - let res = client.try_finalize_arbitrator_change(&admin); - assert!(res.is_err()); - - // Advance time past timelock and finalize - set_time(&env, 1_000 + 86400 + 10); - client.finalize_arbitrator_change(&admin).unwrap(); - - // Verify current arbitrator - let cur = client.get_arbitrator(); - assert_eq!(cur, Some(a0.clone())); - - // Register a quest so resolve_dispute can fetch quest data safely - let quest_id = symbol_short!("Q1"); - let token = Address::generate(&env); - let verifier = Address::generate(&env); - client.register_quest(&quest_id, &admin, &token, &1000, &verifier, &1_000_000).unwrap(); - - // Open dispute (initiator) - let initiator = Address::generate(&env); - let d: Dispute = client.open_dispute(&quest_id, &initiator, &a0).unwrap(); - assert_eq!(d.status, DisputeStatus::Pending); - - // Schedule rotation to A1 - let a1 = Address::generate(&env); - client.schedule_arbitrator_change(&admin, &a1).unwrap(); - // advance and finalize - set_time(&env, env.ledger().timestamp() as u64 + 86400 + 5); - client.finalize_arbitrator_change(&admin).unwrap(); - - // Old arbitrator A0 should no longer be authorized to resolve - let res_old = client.try_resolve_dispute(&quest_id, &initiator, &a0, true, 0); - assert!(res_old.is_err(), "old arbitrator must not be able to resolve after rotation"); - - // New arbitrator can resolve - client.resolve_dispute(&quest_id, &initiator, &a1, true, 0).unwrap(); -} diff --git a/contracts/earn-quest/src/test_stats.rs b/contracts/earn-quest/src/test_stats.rs index 7d4c1e8d8..bf3b65190 100644 --- a/contracts/earn-quest/src/test_stats.rs +++ b/contracts/earn-quest/src/test_stats.rs @@ -16,9 +16,9 @@ #![cfg(test)] use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{symbol_short, Address, BytesN, Env}; +use soroban_sdk::{symbol_short, token, Address, BytesN, Env, Symbol}; -use earn_quest::{EarnQuestContract, EarnQuestContractClient}; +use crate::{EarnQuestContract, EarnQuestContractClient}; // --------------------------------------------------------------------------- // Test helpers @@ -52,52 +52,57 @@ fn setup(env: &Env) -> (EarnQuestContractClient<'_>, Address) { } fn mock_token(env: &Env) -> Address { - Address::generate(env) + let admin = Address::generate(env); + let token_obj = env.register_stellar_asset_contract_v2(admin.clone()); + token_obj.address() } +/// Register a quest using an already-created Symbol. fn register_quest( client: &EarnQuestContractClient, env: &Env, - id: &str, + quest_id: &Symbol, creator: &Address, reward_amount: i128, -) -> soroban_sdk::Symbol { - let quest_id = symbol_short!(id); +) { let token = mock_token(env); let verifier = Address::generate(env); - let deadline = env.ledger().timestamp() + 86_400; - client.register_quest(&quest_id, creator, &token, &reward_amount, &verifier, &deadline); - quest_id + let deadline = env.ledger().timestamp().saturating_add(86_400); + client.register_quest(quest_id, creator, &token, &reward_amount, &verifier, &deadline); } fn submit( client: &EarnQuestContractClient, env: &Env, - quest_id: &soroban_sdk::Symbol, + quest_id: &Symbol, submitter: &Address, ) { let proof: BytesN<32> = BytesN::from_array(env, &[1u8; 32]); client.submit_proof(quest_id, submitter, &proof); } +/// Register + submit + approve + claim in one shot using a real token. fn full_lifecycle( client: &EarnQuestContractClient, env: &Env, - quest_sym: &str, + quest_id: &Symbol, creator: &Address, submitter: &Address, reward_amount: i128, -) -> soroban_sdk::Symbol { - let quest_id = symbol_short!(quest_sym); - let token = mock_token(env); +) { + let token_admin = Address::generate(env); + let token_obj = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_addr = token_obj.address(); + let token_client = token::StellarAssetClient::new(env, &token_addr); let verifier = Address::generate(env); - let deadline = env.ledger().timestamp() + 86_400; - client.register_quest(&quest_id, creator, &token, &reward_amount, &verifier, &deadline); + let deadline = env.ledger().timestamp().saturating_add(86_400); + client.register_quest(quest_id, creator, &token_addr, &reward_amount, &verifier, &deadline); + // Fund the contract so it can pay out the reward + token_client.mint(&client.address, &reward_amount); let proof: BytesN<32> = BytesN::from_array(env, &[2u8; 32]); - client.submit_proof(&quest_id, submitter, &proof); - client.approve_submission(&quest_id, submitter, &verifier); - client.claim_reward(&quest_id, submitter, &reward_amount); - quest_id + client.submit_proof(quest_id, submitter, &proof); + client.approve_submission(quest_id, submitter, &verifier); + client.claim_reward(quest_id, submitter, &reward_amount); } // --------------------------------------------------------------------------- @@ -143,7 +148,7 @@ fn test_platform_quest_count_increments_on_single_create() { let (client, _) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 500); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 500); let stats = client.get_platform_stats(); assert_eq!(stats.total_quests_created, 1); @@ -156,9 +161,9 @@ fn test_platform_quest_count_increments_multiple_times() { let (client, _) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 100); - register_quest(&client, &env, "q002", &creator, 200); - register_quest(&client, &env, "q003", &creator, 300); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 100); + register_quest(&client, &env, &symbol_short!("q002"), &creator, 200); + register_quest(&client, &env, &symbol_short!("q003"), &creator, 300); assert_eq!(client.get_platform_stats().total_quests_created, 3); } @@ -170,8 +175,8 @@ fn test_platform_rewards_distributed_tracks_sum_of_reward_amounts() { let (client, _) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 1_000); - register_quest(&client, &env, "q002", &creator, 2_500); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 1_000); + register_quest(&client, &env, &symbol_short!("q002"), &creator, 2_500); assert_eq!(client.get_platform_stats().total_rewards_distributed, 3_500); } @@ -183,8 +188,8 @@ fn test_creator_quest_count_and_rewards_posted_track_correctly() { let (client, _) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 400); - register_quest(&client, &env, "q002", &creator, 600); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 400); + register_quest(&client, &env, &symbol_short!("q002"), &creator, 600); let c = client.get_creator_stats(&creator); assert_eq!(c.quests_created, 2); @@ -199,9 +204,9 @@ fn test_platform_quest_count_is_monotonically_increasing() { let creator = Address::generate(&env); assert_eq!(client.get_platform_stats().total_quests_created, 0); - register_quest(&client, &env, "q001", &creator, 50); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 50); assert_eq!(client.get_platform_stats().total_quests_created, 1); - register_quest(&client, &env, "q002", &creator, 50); + register_quest(&client, &env, &symbol_short!("q002"), &creator, 50); assert_eq!(client.get_platform_stats().total_quests_created, 2); } @@ -217,7 +222,8 @@ fn test_platform_submission_count_increments_on_submit() { let creator = Address::generate(&env); let submitter = Address::generate(&env); - let qid = register_quest(&client, &env, "q001", &creator, 500); + let qid = symbol_short!("q001"); + register_quest(&client, &env, &qid, &creator, 500); submit(&client, &env, &qid, &submitter); assert_eq!(client.get_platform_stats().total_submissions, 1); @@ -231,7 +237,8 @@ fn test_platform_active_users_increments_on_first_submission() { let creator = Address::generate(&env); let user = Address::generate(&env); - let qid = register_quest(&client, &env, "q001", &creator, 500); + let qid = symbol_short!("q001"); + register_quest(&client, &env, &qid, &creator, 500); submit(&client, &env, &qid, &user); assert_eq!(client.get_platform_stats().total_active_users, 1); @@ -239,15 +246,16 @@ fn test_platform_active_users_increments_on_first_submission() { #[test] fn test_platform_active_users_does_not_double_count_same_user() { - // Same user submits on two different quests — must count as 1 unique user. let env = make_env(); set_time(&env, 1_000); let (client, _) = setup(&env); let creator = Address::generate(&env); let user = Address::generate(&env); - let qid1 = register_quest(&client, &env, "q001", &creator, 100); - let qid2 = register_quest(&client, &env, "q002", &creator, 200); + let qid1 = symbol_short!("q001"); + let qid2 = symbol_short!("q002"); + register_quest(&client, &env, &qid1, &creator, 100); + register_quest(&client, &env, &qid2, &creator, 200); submit(&client, &env, &qid1, &user); submit(&client, &env, &qid2, &user); @@ -266,9 +274,12 @@ fn test_platform_active_users_counts_each_distinct_address_once() { let u2 = Address::generate(&env); let u3 = Address::generate(&env); - let qid1 = register_quest(&client, &env, "q001", &creator, 300); - let qid2 = register_quest(&client, &env, "q002", &creator, 300); - let qid3 = register_quest(&client, &env, "q003", &creator, 300); + let qid1 = symbol_short!("q001"); + let qid2 = symbol_short!("q002"); + let qid3 = symbol_short!("q003"); + register_quest(&client, &env, &qid1, &creator, 300); + register_quest(&client, &env, &qid2, &creator, 300); + register_quest(&client, &env, &qid3, &creator, 300); submit(&client, &env, &qid1, &u1); submit(&client, &env, &qid2, &u2); @@ -286,7 +297,8 @@ fn test_creator_submissions_received_increments_per_submission() { let u1 = Address::generate(&env); let u2 = Address::generate(&env); - let qid = register_quest(&client, &env, "q001", &creator, 500); + let qid = symbol_short!("q001"); + register_quest(&client, &env, &qid, &creator, 500); submit(&client, &env, &qid, &u1); submit(&client, &env, &qid, &u2); @@ -296,15 +308,16 @@ fn test_creator_submissions_received_increments_per_submission() { #[test] fn test_submission_count_increments_per_submission_not_per_user() { - // Same user, two quests → 2 submissions total but 1 unique active user. let env = make_env(); set_time(&env, 1_000); let (client, _) = setup(&env); let creator = Address::generate(&env); let user = Address::generate(&env); - let qid1 = register_quest(&client, &env, "q001", &creator, 100); - let qid2 = register_quest(&client, &env, "q002", &creator, 200); + let qid1 = symbol_short!("q001"); + let qid2 = symbol_short!("q002"); + register_quest(&client, &env, &qid1, &creator, 100); + register_quest(&client, &env, &qid2, &creator, 200); submit(&client, &env, &qid1, &user); submit(&client, &env, &qid2, &user); @@ -325,7 +338,7 @@ fn test_platform_rewards_claimed_increments_on_single_claim() { let creator = Address::generate(&env); let submitter = Address::generate(&env); - full_lifecycle(&client, &env, "q001", &creator, &submitter, 500); + full_lifecycle(&client, &env, &symbol_short!("q001"), &creator, &submitter, 500); assert_eq!(client.get_platform_stats().total_rewards_claimed, 1); } @@ -339,8 +352,8 @@ fn test_platform_rewards_claimed_increments_on_multiple_claims() { let u1 = Address::generate(&env); let u2 = Address::generate(&env); - full_lifecycle(&client, &env, "q001", &creator, &u1, 300); - full_lifecycle(&client, &env, "q002", &creator, &u2, 700); + full_lifecycle(&client, &env, &symbol_short!("q001"), &creator, &u1, 300); + full_lifecycle(&client, &env, &symbol_short!("q002"), &creator, &u2, 700); assert_eq!(client.get_platform_stats().total_rewards_claimed, 2); } @@ -354,8 +367,8 @@ fn test_creator_claims_paid_increments_after_each_claim() { let u1 = Address::generate(&env); let u2 = Address::generate(&env); - full_lifecycle(&client, &env, "q001", &creator, &u1, 400); - full_lifecycle(&client, &env, "q002", &creator, &u2, 600); + full_lifecycle(&client, &env, &symbol_short!("q001"), &creator, &u1, 400); + full_lifecycle(&client, &env, &symbol_short!("q002"), &creator, &u2, 600); let c = client.get_creator_stats(&creator); assert_eq!(c.total_claims_paid, 2); @@ -369,9 +382,9 @@ fn test_submission_without_claim_does_not_increment_rewards_claimed() { let creator = Address::generate(&env); let submitter = Address::generate(&env); - let qid = register_quest(&client, &env, "q001", &creator, 500); + let qid = symbol_short!("q001"); + register_quest(&client, &env, &qid, &creator, 500); submit(&client, &env, &qid, &submitter); - // No approve or claim assert_eq!(client.get_platform_stats().total_rewards_claimed, 0); } @@ -389,14 +402,14 @@ fn test_creator_stats_are_isolated_between_creators() { let cb = Address::generate(&env); let submitter = Address::generate(&env); - // creator_a: 2 quests - register_quest(&client, &env, "a001", &ca, 1_000); - register_quest(&client, &env, "a002", &ca, 2_000); - // creator_b: 1 quest - register_quest(&client, &env, "b001", &cb, 500); + let qa1 = symbol_short!("a001"); + let qa2 = symbol_short!("a002"); + let qb1 = symbol_short!("b001"); + register_quest(&client, &env, &qa1, &ca, 1_000); + register_quest(&client, &env, &qa2, &ca, 2_000); + register_quest(&client, &env, &qb1, &cb, 500); - // submit to creator_a's quest only - submit(&client, &env, &symbol_short!("a001"), &submitter); + submit(&client, &env, &qa1, &submitter); let ca_stats = client.get_creator_stats(&ca); let cb_stats = client.get_creator_stats(&cb); @@ -418,8 +431,8 @@ fn test_platform_aggregates_across_all_creators() { let ca = Address::generate(&env); let cb = Address::generate(&env); - register_quest(&client, &env, "a001", &ca, 1_000); - register_quest(&client, &env, "b001", &cb, 2_000); + register_quest(&client, &env, &symbol_short!("a001"), &ca, 1_000); + register_quest(&client, &env, &symbol_short!("b001"), &cb, 2_000); let stats = client.get_platform_stats(); assert_eq!(stats.total_quests_created, 2); @@ -436,12 +449,11 @@ fn test_creator_claims_paid_isolated_from_other_creator_claims() { let u1 = Address::generate(&env); let u2 = Address::generate(&env); - full_lifecycle(&client, &env, "a001", &ca, &u1, 300); - full_lifecycle(&client, &env, "b001", &cb, &u2, 700); + full_lifecycle(&client, &env, &symbol_short!("a001"), &ca, &u1, 300); + full_lifecycle(&client, &env, &symbol_short!("b001"), &cb, &u2, 700); assert_eq!(client.get_creator_stats(&ca).total_claims_paid, 1); assert_eq!(client.get_creator_stats(&cb).total_claims_paid, 1); - // Platform sees both assert_eq!(client.get_platform_stats().total_rewards_claimed, 2); } @@ -461,16 +473,19 @@ fn test_full_platform_lifecycle_all_counters_consistent() { let u2 = Address::generate(&env); let u3 = Address::generate(&env); - register_quest(&client, &env, "a001", &ca, 100); - register_quest(&client, &env, "a002", &ca, 200); - register_quest(&client, &env, "b001", &cb, 300); - register_quest(&client, &env, "b002", &cb, 400); + let qa1 = symbol_short!("a001"); + let qa2 = symbol_short!("a002"); + let qb1 = symbol_short!("b001"); + let qb2 = symbol_short!("b002"); + register_quest(&client, &env, &qa1, &ca, 100); + register_quest(&client, &env, &qa2, &ca, 200); + register_quest(&client, &env, &qb1, &cb, 300); + register_quest(&client, &env, &qb2, &cb, 400); - // u1 submits twice; u2 and u3 each submit once - submit(&client, &env, &symbol_short!("a001"), &u1); - submit(&client, &env, &symbol_short!("a002"), &u2); - submit(&client, &env, &symbol_short!("b001"), &u1); // second submission by u1 - submit(&client, &env, &symbol_short!("b002"), &u3); + submit(&client, &env, &qa1, &u1); + submit(&client, &env, &qa2, &u2); + submit(&client, &env, &qb1, &u1); + submit(&client, &env, &qb2, &u3); let stats = client.get_platform_stats(); assert_eq!(stats.total_quests_created, 4); @@ -491,7 +506,7 @@ fn test_minimum_reward_amount_tracked_correctly() { let (client, _) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 1); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 1); assert_eq!(client.get_platform_stats().total_rewards_distributed, 1); } @@ -503,8 +518,9 @@ fn test_large_reward_amount_tracked_without_overflow() { let (client, _) = setup(&env); let creator = Address::generate(&env); - let big: i128 = i128::MAX / 4; - register_quest(&client, &env, "q001", &creator, big); + // MAX_REWARD_AMOUNT validation ceiling + let big: i128 = 1_000_000_000_000_000_i128; + register_quest(&client, &env, &symbol_short!("q001"), &creator, big); let stats = client.get_platform_stats(); assert_eq!(stats.total_rewards_distributed, big as u128); @@ -521,7 +537,7 @@ fn test_admin_can_reset_platform_stats_to_zero() { let (client, admin) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 500); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 500); assert_eq!(client.get_platform_stats().total_quests_created, 1); client.reset_platform_stats(&admin); @@ -552,11 +568,10 @@ fn test_stats_accumulate_correctly_after_reset() { let (client, admin) = setup(&env); let creator = Address::generate(&env); - register_quest(&client, &env, "q001", &creator, 500); + register_quest(&client, &env, &symbol_short!("q001"), &creator, 500); client.reset_platform_stats(&admin); - // A quest created after reset should restart from 1 - register_quest(&client, &env, "q002", &creator, 250); + register_quest(&client, &env, &symbol_short!("q002"), &creator, 250); let stats = client.get_platform_stats(); assert_eq!(stats.total_quests_created, 1, "counter must restart after reset"); assert_eq!(stats.total_rewards_distributed, 250); @@ -568,18 +583,15 @@ fn test_stats_accumulate_correctly_after_reset() { #[test] fn test_get_platform_stats_requires_no_auth() { - // Intentionally not calling mock_all_auths globally for this test. let env = Env::default(); set_time(&env, 1_000); let cid = env.register_contract(None, EarnQuestContract); let client = EarnQuestContractClient::new(&env, &cid); - // initialize still requires auth env.mock_all_auths(); let admin = Address::generate(&env); client.initialize(&admin); - // get_platform_stats must not require auth — call without mock let stats = client.get_platform_stats(); assert_eq!(stats.total_quests_created, 0); } @@ -598,4 +610,4 @@ fn test_get_creator_stats_requires_no_auth() { let creator = Address::generate(&env); let c = client.get_creator_stats(&creator); assert_eq!(c.quests_created, 0); -} \ No newline at end of file +} diff --git a/contracts/earn-quest/src/test_thresholds.rs b/contracts/earn-quest/src/test_thresholds.rs deleted file mode 100644 index 50db35058..000000000 --- a/contracts/earn-quest/src/test_thresholds.rs +++ /dev/null @@ -1,77 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; -use soroban_sdk::{symbol_short, Address, Env, Vec}; - -use earn_quest::{EarnQuestContract, EarnQuestContractClient, Badge}; - -fn make_env() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env -} - -fn set_time(env: &Env, ts: u64) { - env.ledger().set(LedgerInfo { - protocol_version: 20, - sequence_number: 1, - timestamp: ts, - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 100, - min_persistent_entry_ttl: 100, - max_entry_ttl: 1_000_000, - }); -} - -fn setup(env: &Env) -> (EarnQuestContractClient, Address) { - let cid = env.register_contract(None, EarnQuestContract); - let client = EarnQuestContractClient::new(env, &cid); - let admin = Address::generate(env); - client.initialize(&admin); - (client, admin) -} - -#[test] -fn test_superadmin_can_update_level_thresholds_and_recalc() { - let env = make_env(); - set_time(&env, 1_000); - let (client, admin) = setup(&env); - - // New thresholds: level2=50, level3=100, level4=200, level5=400 - let mut thresholds: Vec = Vec::new(&env); - thresholds.push_back(50u64); - thresholds.push_back(100u64); - thresholds.push_back(200u64); - thresholds.push_back(400u64); - - // SuperAdmin updates thresholds - client.set_level_thresholds(&admin, thresholds); - - // New user - let user = Address::generate(&env); - let stats_before = client.get_user_stats(&user); - assert_eq!(stats_before.level, 1); - - // Grant a Legend badge (100 XP) - should push user to level 3 under new thresholds - client.grant_badge(&admin, &user, Badge::Legend).unwrap(); - - let stats_after = client.get_user_stats(&user); - assert_eq!(stats_after.xp, 100); - assert_eq!(stats_after.level, 3); -} - -#[test] -fn test_non_superadmin_cannot_update_thresholds() { - let env = make_env(); - set_time(&env, 1_000); - let (client, _admin) = setup(&env); - - let random = Address::generate(&env); - let mut thresholds: Vec = Vec::new(&env); - thresholds.push_back(10u64); - thresholds.push_back(20u64); - - let res = client.try_set_level_thresholds(&random, thresholds); - assert!(res.is_err(), "non-superadmin must be rejected"); -} diff --git a/contracts/earn-quest/src/token.rs b/contracts/earn-quest/src/token.rs index 436b5ccf9..257d120d8 100644 --- a/contracts/earn-quest/src/token.rs +++ b/contracts/earn-quest/src/token.rs @@ -49,7 +49,7 @@ pub fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amoun if allowance < amount { panic!("insufficient allowance"); } - write_allowance(&e, from.clone(), spender, allowance - amount); + write_allowance(&e, from.clone(), spender, allowance.saturating_sub(amount)); do_transfer(&e, from, to, amount); } @@ -59,7 +59,7 @@ pub fn burn(e: Env, from: Address, amount: i128) { if balance < amount { panic!("insufficient balance"); } - write_balance(&e, from, balance - amount); + write_balance(&e, from, balance.saturating_sub(amount)); } pub fn burn_from(e: Env, spender: Address, from: Address, amount: i128) { @@ -68,12 +68,12 @@ pub fn burn_from(e: Env, spender: Address, from: Address, amount: i128) { if allowance < amount { panic!("insufficient allowance"); } - write_allowance(&e, from.clone(), spender, allowance - amount); + write_allowance(&e, from.clone(), spender, allowance.saturating_sub(amount)); let balance = read_balance(&e, from.clone()); if balance < amount { panic!("insufficient balance"); } - write_balance(&e, from, balance - amount); + write_balance(&e, from, balance.saturating_sub(amount)); } pub fn decimals(e: Env) -> u32 { @@ -102,14 +102,14 @@ fn do_transfer(e: &Env, from: Address, to: Address, amount: i128) { if balance_from < amount { panic!("insufficient balance"); } - write_balance(e, from, balance_from - amount); + write_balance(e, from, balance_from.saturating_sub(amount)); let balance_to = read_balance(e, to.clone()); - write_balance(e, to, balance_to + amount); + write_balance(e, to, balance_to.saturating_add(amount)); } pub fn mint(e: Env, to: Address, amount: i128) { let balance = read_balance(&e, to.clone()); - write_balance(&e, to, balance + amount); + write_balance(&e, to, balance.saturating_add(amount)); } pub fn set_metadata(e: &Env, name: String, symbol: String, decimals: u32) { diff --git a/contracts/earn-quest/src/unified_keys.rs b/contracts/earn-quest/src/unified_keys.rs deleted file mode 100644 index 0ab3c977d..000000000 --- a/contracts/earn-quest/src/unified_keys.rs +++ /dev/null @@ -1,19 +0,0 @@ -use soroban_sdk::contracttype; - -/// Unified storage key definitions for the earn-quest contract. -/// All on-chain data keys are declared here to avoid collisions and -/// provide a single source of truth for storage layout. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - /// The contract administrator address. - Admin, - /// Native token balance for a given user address. - Balance(soroban_sdk::Address), - /// Metadata / reward data for a quest identified by its numeric ID. - QuestData(u64), - /// A specific user's progress on a specific quest. - UserProgress(soroban_sdk::Address, u64), - /// General contract configuration blob. - Config, -} diff --git a/contracts/earn-quest/src/validation.rs b/contracts/earn-quest/src/validation.rs index b774caaaa..1327a3362 100644 --- a/contracts/earn-quest/src/validation.rs +++ b/contracts/earn-quest/src/validation.rs @@ -121,7 +121,7 @@ pub fn validate_deadline(env: &Env, deadline: u64) -> Result<(), Error> { return Err(Error::DeadlineInPast); } - let duration = deadline - current_time; + let duration = deadline.saturating_sub(current_time); // Enforce minimum relative duration to prevent single-ledger timestamp nudging if duration < MIN_DEADLINE_DURATION { diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.13.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.13.json index 76288a2a7..f24782344 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.13.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.13.json @@ -5728,7 +5728,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" @@ -6705,7 +6705,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.15.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.15.json index e52873ad2..4a081fe78 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.15.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.15.json @@ -4957,7 +4957,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.17.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.17.json index 0d02340a3..b2ea3d87e 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.17.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.17.json @@ -3237,7 +3237,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.19.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.19.json index b2af2d6dd..65da1a243 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.19.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.19.json @@ -7010,7 +7010,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" @@ -7987,7 +7987,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.21.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.21.json index f086a4e7d..bc6887fd5 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.21.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.21.json @@ -7308,7 +7308,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.49.json b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.49.json index c82f4d1e9..e7c7abd02 100644 --- a/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.49.json +++ b/contracts/earn-quest/test_snapshots/contract_all_lifecycle_invariants_hold.49.json @@ -4044,7 +4044,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "LCCON" diff --git a/contracts/earn-quest/test_snapshots/test_created_at_timestamp.1.json b/contracts/earn-quest/test_snapshots/test_created_at_timestamp.1.json index d8592078e..4c2e15279 100644 --- a/contracts/earn-quest/test_snapshots/test_created_at_timestamp.1.json +++ b/contracts/earn-quest/test_snapshots/test_created_at_timestamp.1.json @@ -2326,7 +2326,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qstamp" diff --git a/contracts/earn-quest/test_snapshots/test_deposit_count_accuracy.1.json b/contracts/earn-quest/test_snapshots/test_deposit_count_accuracy.1.json index 8e787eef3..e190d79ce 100644 --- a/contracts/earn-quest/test_snapshots/test_deposit_count_accuracy.1.json +++ b/contracts/earn-quest/test_snapshots/test_deposit_count_accuracy.1.json @@ -2504,7 +2504,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qdcnt" @@ -2832,7 +2832,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qdcnt" @@ -3160,7 +3160,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qdcnt" diff --git a/contracts/earn-quest/test_snapshots/test_escrow_topup_accumulates_in_balances.1.json b/contracts/earn-quest/test_snapshots/test_escrow_topup_accumulates_in_balances.1.json index 70de46920..4759552ee 100644 --- a/contracts/earn-quest/test_snapshots/test_escrow_topup_accumulates_in_balances.1.json +++ b/contracts/earn-quest/test_snapshots/test_escrow_topup_accumulates_in_balances.1.json @@ -2282,7 +2282,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "QTOP" @@ -2479,7 +2479,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "QTOP" diff --git a/contracts/earn-quest/test_snapshots/test_full_lifecycle_with_topup.1.json b/contracts/earn-quest/test_snapshots/test_full_lifecycle_with_topup.1.json index 18082e447..63b8795c4 100644 --- a/contracts/earn-quest/test_snapshots/test_full_lifecycle_with_topup.1.json +++ b/contracts/earn-quest/test_snapshots/test_full_lifecycle_with_topup.1.json @@ -4367,7 +4367,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qlife2" diff --git a/contracts/earn-quest/test_snapshots/test_multiple_topups_tracked.1.json b/contracts/earn-quest/test_snapshots/test_multiple_topups_tracked.1.json index d76a28e44..f7263dcac 100644 --- a/contracts/earn-quest/test_snapshots/test_multiple_topups_tracked.1.json +++ b/contracts/earn-quest/test_snapshots/test_multiple_topups_tracked.1.json @@ -2460,7 +2460,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qtop" @@ -2657,7 +2657,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qtop" @@ -2854,7 +2854,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qtop" @@ -3051,7 +3051,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qtop" diff --git a/contracts/earn-quest/test_snapshots/test_topup_escrow.1.json b/contracts/earn-quest/test_snapshots/test_topup_escrow.1.json index 02d669579..370325077 100644 --- a/contracts/earn-quest/test_snapshots/test_topup_escrow.1.json +++ b/contracts/earn-quest/test_snapshots/test_topup_escrow.1.json @@ -2248,7 +2248,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "q2" diff --git a/contracts/earn-quest/test_snapshots/test_topup_unblocks_approval_after_depletion.1.json b/contracts/earn-quest/test_snapshots/test_topup_unblocks_approval_after_depletion.1.json index fbc083ad2..b6e7172ef 100644 --- a/contracts/earn-quest/test_snapshots/test_topup_unblocks_approval_after_depletion.1.json +++ b/contracts/earn-quest/test_snapshots/test_topup_unblocks_approval_after_depletion.1.json @@ -3682,7 +3682,7 @@ "v0": { "topics": [ { - "symbol": "esc_dep" + "symbol": "esc_top" }, { "symbol": "qunbl" diff --git a/contracts/earn-quest/tests/test_escrow.rs b/contracts/earn-quest/tests/test_escrow.rs index 3e5b1c6d4..d2301982a 100644 --- a/contracts/earn-quest/tests/test_escrow.rs +++ b/contracts/earn-quest/tests/test_escrow.rs @@ -1052,3 +1052,140 @@ fn test_escrow_info_reflects_correct_depositor() { assert_eq!(info.token, t.token_address); assert_eq!(info.quest_id, qid); } + +// ══════════════════════════════════════════════════════════════ +// SECTION 13: ESCROW TOP-UP EVENT EMISSION (#1721) +// ══════════════════════════════════════════════════════════════ + +/// Verifies that the first `deposit_escrow` call emits an `esc_dep` event +/// (initial deposit) and that a subsequent call emits an `esc_top` event +/// (top-up) with the correct indexed `quest_id` topic and correct data +/// (top-up amount, new available balance). +#[test] +fn test_topup_emits_escrow_topped_up_event() { + use soroban_sdk::testutils::Events; + use soroban_sdk::IntoVal; + + let t = setup(); + let qid = symbol_short!("qtopev"); + register_quest(&t, &qid); + + // ── Initial deposit ────────────────────────────────────── + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &3000); + + let events_after_first = t.env.events().all(); + // The last event must be the initial escrow_deposited (esc_dep) + let (_, topics, _) = events_after_first.last().unwrap(); + let event_name: Symbol = topics.get(0).unwrap().into_val(&t.env); + assert_eq!( + event_name, + symbol_short!("esc_dep"), + "first deposit should emit esc_dep, not esc_top" + ); + + // ── Top-up ─────────────────────────────────────────────── + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &2000); + + let events_after_topup = t.env.events().all(); + let (contract_addr, topics, data) = events_after_topup.last().unwrap(); + + // Emitted by our contract + assert_eq!(contract_addr, t.contract.address); + + // Topics[0] == "esc_top" + let event_name: Symbol = topics.get(0).unwrap().into_val(&t.env); + assert_eq!( + event_name, + symbol_short!("esc_top"), + "top-up should emit esc_top event" + ); + + // Topics[1] == quest_id (indexed for efficient filtering) + let indexed_quest_id: Symbol = topics.get(1).unwrap().into_val(&t.env); + assert_eq!( + indexed_quest_id, qid, + "quest_id must be the indexed topic on EscrowToppedUp" + ); + + // Topics[2] == depositor address + let indexed_depositor: Address = topics.get(2).unwrap().into_val(&t.env); + assert_eq!(indexed_depositor, t.creator); + + // Data: (amount: i128, new_balance: i128) + let (amount, new_balance): (i128, i128) = data.into_val(&t.env); + assert_eq!(amount, 2000, "event amount should equal the top-up amount"); + assert_eq!( + new_balance, 5000, + "new_balance should be the total available after top-up (3000 + 2000)" + ); +} + +/// Verifies that multiple successive top-ups each emit an `esc_top` event +/// and that the `new_balance` value in the data payload accumulates correctly. +#[test] +fn test_multiple_topups_each_emit_event() { + use soroban_sdk::testutils::Events; + use soroban_sdk::IntoVal; + + let t = setup(); + let qid = symbol_short!("qmulte"); + register_quest(&t, &qid); + + // Initial deposit + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &1000); + + // Top-up 1 + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &500); + let evs = t.env.events().all(); + let (_, topics, data) = evs.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().into_val(&t.env); + assert_eq!(name, symbol_short!("esc_top")); + let (amt, bal): (i128, i128) = data.into_val(&t.env); + assert_eq!(amt, 500); + assert_eq!(bal, 1500); + + // Top-up 2 + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &250); + let evs = t.env.events().all(); + let (_, topics, data) = evs.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().into_val(&t.env); + assert_eq!(name, symbol_short!("esc_top")); + let (amt, bal): (i128, i128) = data.into_val(&t.env); + assert_eq!(amt, 250); + assert_eq!(bal, 1750); +} + +/// Verifies that the `new_balance` in the top-up event correctly reflects +/// the net available balance after a partial payout has been made. +#[test] +fn test_topup_event_new_balance_after_payout() { + use soroban_sdk::testutils::Events; + use soroban_sdk::IntoVal; + + let t = setup(); + let qid = symbol_short!("qtopbal"); + register_quest(&t, &qid); + + // Deposit 2000, pay out 1000 (one completion), then top up 500 + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &2000); + complete_quest(&t, &qid, &t.user_a); // escrow now 1000 + + t.contract + .deposit_escrow(&qid, &t.creator, &t.token_address, &500); + + let evs = t.env.events().all(); + let (_, topics, data) = evs.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().into_val(&t.env); + assert_eq!(name, symbol_short!("esc_top")); + + // new_balance should be 1000 (remaining after payout) + 500 (top-up) = 1500 + let (amt, new_bal): (i128, i128) = data.into_val(&t.env); + assert_eq!(amt, 500); + assert_eq!(new_bal, 1500); +} diff --git a/contracts/earn-quest/tests/test_events.rs b/contracts/earn-quest/tests/test_events.rs index 565be6061..b86f28013 100644 --- a/contracts/earn-quest/tests/test_events.rs +++ b/contracts/earn-quest/tests/test_events.rs @@ -143,3 +143,166 @@ fn test_full_quest_lifecycle_events() { assert_eq!(total_xp, 100); assert_eq!(level, 1); } + +#[test] +fn test_escrow_topped_up_event_emitted_on_second_deposit() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, EarnQuestContract); + let client = EarnQuestContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + + // Deploy a real token so transfers succeed + let token_obj = env.register_stellar_asset_contract_v2(admin.clone()); + let token_address = token_obj.address(); + let token_admin = StellarAssetClient::new(&env, &token_address); + // Mint enough for two deposits + token_admin.mint(&creator, &2_000_i128); + + client.initialize(&admin); + + let quest_id = symbol_short!("q_topup"); + let reward_amount = 500_i128; + let deadline = 99_999_u64; + + client.register_quest( + &quest_id, + &creator, + &token_address, + &reward_amount, + &verifier, + &deadline, + ); + + // ── First deposit: should emit `esc_dep`, NOT `esc_top` ────────────────── + client.deposit_escrow(&quest_id, &creator, &token_address, &1_000_i128); + + let events_after_first = env.events().all(); + let (_, first_topics, _) = events_after_first.last().unwrap(); + let first_event_name: Symbol = first_topics.get(0).unwrap().into_val(&env); + assert_eq!( + first_event_name, + symbol_short!("esc_dep"), + "initial deposit must emit esc_dep" + ); + + // ── Second deposit (top-up): must emit `esc_top` ──────────────────────── + client.deposit_escrow(&quest_id, &creator, &token_address, &500_i128); + + let events_after_topup = env.events().all(); + let (contract, topup_topics, topup_data) = events_after_topup.last().unwrap(); + + assert_eq!(contract, contract_id, "event must come from our contract"); + + // Topics: [esc_top, quest_id, depositor, token] + let t_name: Symbol = topup_topics.get(0).unwrap().into_val(&env); + let t_quest: Symbol = topup_topics.get(1).unwrap().into_val(&env); + let t_depositor: Address = topup_topics.get(2).unwrap().into_val(&env); + let t_token: Address = topup_topics.get(3).unwrap().into_val(&env); + + assert_eq!(t_name, symbol_short!("esc_top"), "top-up event name must be esc_top"); + assert_eq!(t_quest, quest_id, "quest_id topic must match"); + assert_eq!(t_depositor, creator, "depositor topic must match"); + assert_eq!(t_token, token_address, "token topic must match"); + + // Data: (amount, new_balance) + let (topup_amount, new_balance): (i128, i128) = topup_data.into_val(&env); + assert_eq!(topup_amount, 500_i128, "top-up amount in data must match"); + assert_eq!(new_balance, 1_500_i128, "new_balance must reflect cumulative deposits"); +} + +#[test] +fn test_escrow_topped_up_event_not_emitted_on_initial_deposit() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, EarnQuestContract); + let client = EarnQuestContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + + let token_obj = env.register_stellar_asset_contract_v2(admin.clone()); + let token_address = token_obj.address(); + let token_admin = StellarAssetClient::new(&env, &token_address); + token_admin.mint(&creator, &1_000_i128); + + client.initialize(&admin); + + let quest_id = symbol_short!("q_init"); + client.register_quest( + &quest_id, + &creator, + &token_address, + &500_i128, + &verifier, + &99_999_u64, + ); + + client.deposit_escrow(&quest_id, &creator, &token_address, &1_000_i128); + + // The last event must be esc_dep, never esc_top + let events = env.events().all(); + let (_, topics, _) = events.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().into_val(&env); + assert_eq!(name, symbol_short!("esc_dep"), "first deposit must emit esc_dep not esc_top"); + assert_ne!( + name, + symbol_short!("esc_top"), + "esc_top must NOT be emitted on initial deposit" + ); +} + +#[test] +fn test_escrow_topped_up_event_emitted_on_every_subsequent_deposit() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, EarnQuestContract); + let client = EarnQuestContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + + let token_obj = env.register_stellar_asset_contract_v2(admin.clone()); + let token_address = token_obj.address(); + let token_admin = StellarAssetClient::new(&env, &token_address); + token_admin.mint(&creator, &5_000_i128); + + client.initialize(&admin); + + let quest_id = symbol_short!("q_multi"); + client.register_quest( + &quest_id, + &creator, + &token_address, + &500_i128, + &verifier, + &99_999_u64, + ); + + // Initial deposit + client.deposit_escrow(&quest_id, &creator, &token_address, &1_000_i128); + + // Top-up 1 + client.deposit_escrow(&quest_id, &creator, &token_address, &500_i128); + let (_, t1, d1) = env.events().all().last().unwrap(); + let n1: Symbol = t1.get(0).unwrap().into_val(&env); + let (_, bal1): (i128, i128) = d1.into_val(&env); + assert_eq!(n1, symbol_short!("esc_top"), "second deposit must be esc_top"); + assert_eq!(bal1, 1_500_i128); + + // Top-up 2 + client.deposit_escrow(&quest_id, &creator, &token_address, &500_i128); + let (_, t2, d2) = env.events().all().last().unwrap(); + let n2: Symbol = t2.get(0).unwrap().into_val(&env); + let (_, bal2): (i128, i128) = d2.into_val(&env); + assert_eq!(n2, symbol_short!("esc_top"), "third deposit must also be esc_top"); + assert_eq!(bal2, 2_000_i128); +}