diff --git a/contracts/quest-engine/src/lib.rs b/contracts/quest-engine/src/lib.rs index 56a5002..7f291c1 100644 --- a/contracts/quest-engine/src/lib.rs +++ b/contracts/quest-engine/src/lib.rs @@ -85,6 +85,75 @@ pub struct ExploreQuestVerified { #[contract] pub struct QuestEngineContract; +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Core approval logic shared by `review_submission` and `batch_review_submissions`. +/// +/// Applies the 15 % platform fee, queries the stake-vault multiplier, transfers +/// the learner's share from the escrow (and optionally draws a boost delta from +/// the reward pool), then marks the submission as `Approved`. +/// +/// Callers are responsible for: +/// - Auth / pause checks. +/// - Verifying the submission is `Pending` before calling this function. +/// - Persisting the mutated `Submission` back to storage after the call. +fn approve_submission_inner( + env: &Env, + token_client: &token::Client, + reward_pool: &Address, + stake_vault_address: &Address, + quest: &Quest, + learner: &Address, + submission: &mut Submission, +) { + let fee = (quest.reward_amount * 15) / 100; + let base_learner_amount = quest.reward_amount - fee; + + // Query the learner's staking multiplier (basis points, 100 = 1×). + let stake_vault_client = StakeVaultClient::new(env, stake_vault_address); + let multiplier = stake_vault_client.get_multiplier(learner); + + // Boosted / penalised amount. + let final_learner_amount = (base_learner_amount * multiplier as i128) / 100; + + // 15 % fee always goes to the reward pool. + token_client.transfer(&env.current_contract_address(), reward_pool, &fee); + + if multiplier >= 100 { + // Pay the base amount from escrow … + token_client.transfer( + &env.current_contract_address(), + learner, + &base_learner_amount, + ); + + // … and draw any boost delta from the reward pool. + if final_learner_amount > base_learner_amount { + let boost_delta = final_learner_amount - base_learner_amount; + let reward_pool_client = RewardPoolClient::new(env, reward_pool); + reward_pool_client.distribute_reward( + &env.current_contract_address(), + learner, + &boost_delta, + ); + } + } else { + // Penalty path: learner receives a reduced amount; the shortfall is + // sent to the reward pool on top of the regular fee. + let penalty = base_learner_amount - final_learner_amount; + token_client.transfer( + &env.current_contract_address(), + learner, + &final_learner_amount, + ); + token_client.transfer(&env.current_contract_address(), reward_pool, &penalty); + } + + submission.status = SubmissionStatus::Approved; +} + +// ── Contract implementation ─────────────────────────────────────────────────── + #[contractimpl] impl QuestEngineContract { /// Initializes the QuestEngine contract with the token address and admin. @@ -308,69 +377,35 @@ impl QuestEngineContract { panic!("Submission is not pending review"); } - let token_address: Address = env - .storage() - .instance() - .get(&DataKey::Token) - .expect("Not initialized"); - let token_client = token::Client::new(&env, &token_address); - - let reward_pool: Address = env - .storage() - .instance() - .get(&DataKey::RewardPool) - .expect("Not initialized"); - if approve { - let fee = (quest.reward_amount * 15) / 100; - let base_learner_amount = quest.reward_amount - fee; + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .expect("Not initialized"); + let token_client = token::Client::new(&env, &token_address); + + let reward_pool: Address = env + .storage() + .instance() + .get(&DataKey::RewardPool) + .expect("Not initialized"); - // Fetch stake vault and get multiplier let stake_vault_address: Address = env .storage() .instance() .get(&DataKey::StakeVault) .expect("Not initialized"); - let stake_vault_client = StakeVaultClient::new(&env, &stake_vault_address); - let multiplier = stake_vault_client.get_multiplier(&learner); - - // Calculate amount based on multiplier (basis points) - let final_learner_amount = (base_learner_amount * multiplier as i128) / 100; - - // Transfer fee to reward pool - token_client.transfer(&env.current_contract_address(), &reward_pool, &fee); - - if multiplier >= 100 { - // For multipliers >= 100, pay base from escrow - token_client.transfer( - &env.current_contract_address(), - &learner, - &base_learner_amount, - ); - - // If boosted > base, get the difference from reward pool - if final_learner_amount > base_learner_amount { - let boost_delta = final_learner_amount - base_learner_amount; - let reward_pool_client = RewardPoolClient::new(&env, &reward_pool); - reward_pool_client.distribute_reward( - &env.current_contract_address(), - &learner, - &boost_delta, - ); - } - } else { - // For multipliers < 100 (penalty), pay reduced amount from escrow - // Penalty goes to reward pool - let penalty = base_learner_amount - final_learner_amount; - token_client.transfer( - &env.current_contract_address(), - &learner, - &final_learner_amount, - ); - token_client.transfer(&env.current_contract_address(), &reward_pool, &penalty); - } - submission.status = SubmissionStatus::Approved; + approve_submission_inner( + &env, + &token_client, + &reward_pool, + &stake_vault_address, + &quest, + &learner, + &mut submission, + ); } else { submission.status = SubmissionStatus::Rejected; } @@ -433,7 +468,8 @@ impl QuestEngineContract { .publish(&env); } - /// Approves multiple learner submissions in a single transaction. + /// Approves multiple learner submissions in a single transaction, + /// applying the same fee and staking-multiplier logic as `review_submission`. pub fn batch_review_submissions( env: Env, employer: Address, @@ -471,16 +507,11 @@ impl QuestEngineContract { .get(&DataKey::RewardPool) .expect("Not initialized"); - let fee = (quest.reward_amount * 15) / 100; - let learner_amount = quest.reward_amount - fee; - let total_payout = learner_amount * (learners.len() as i128); - let total_fee = fee * (learners.len() as i128); - - // Check if contract has enough balance - let contract_balance = token_client.balance(&env.current_contract_address()); - if contract_balance < total_payout + total_fee { - panic!("Insufficient quest budget"); - } + let stake_vault_address: Address = env + .storage() + .instance() + .get(&DataKey::StakeVault) + .expect("Not initialized"); let mut approved_count: u32 = 0; for learner in learners.iter() { @@ -495,10 +526,17 @@ impl QuestEngineContract { panic!("Submission is not pending review"); } - token_client.transfer(&env.current_contract_address(), &reward_pool, &fee); - token_client.transfer(&env.current_contract_address(), &learner, &learner_amount); + // Reuse the same approval logic as review_submission. + approve_submission_inner( + &env, + &token_client, + &reward_pool, + &stake_vault_address, + &quest, + &learner, + &mut submission, + ); - submission.status = SubmissionStatus::Approved; env.storage().persistent().set(&submission_key, &submission); SubmissionReviewed { @@ -512,7 +550,7 @@ impl QuestEngineContract { approved_count += 1; } - // Mark quest inactive after all approvals + // Mark quest inactive after all approvals. quest.active = false; env.storage() .persistent() diff --git a/contracts/quest-engine/src/test.rs b/contracts/quest-engine/src/test.rs index 3b49998..afef3fb 100644 --- a/contracts/quest-engine/src/test.rs +++ b/contracts/quest-engine/src/test.rs @@ -19,6 +19,29 @@ impl MockStakeVault { } } +/// Mock StakeVault that stores per-learner multipliers so tests can set +/// different tiers for different learners in the same batch. +#[contract] +pub struct MockStakeVaultPerLearner; + +#[contractimpl] +impl MockStakeVaultPerLearner { + /// Store a multiplier for a specific learner address. + pub fn set_multiplier(env: Env, learner: Address, multiplier: u32) { + env.storage() + .persistent() + .set(&(soroban_sdk::symbol_short!("mul"), learner), &multiplier); + } + + /// Return the stored multiplier, defaulting to 100 (1×) if not set. + pub fn get_multiplier(env: Env, learner: Address) -> u32 { + env.storage() + .persistent() + .get(&(soroban_sdk::symbol_short!("mul"), learner)) + .unwrap_or(100u32) + } +} + /// Mock StakeVault that returns a custom multiplier #[contract] pub struct MockStakeVaultWithMultiplier; @@ -1218,3 +1241,330 @@ fn test_mixed_quest_types() { assert_eq!(build_quest.employer, employer); assert_eq!(explore_quest.employer, admin); } + +// ── batch_review_submissions – multiplier parity tests ────────────────────── + +/// Batch approval with a 120× multiplier vault: each learner should receive +/// the boosted amount, with the delta drawn from the reward pool. +#[test] +fn test_batch_review_with_120_multiplier() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuestEngineContract, ()); + let client = QuestEngineContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let stake_vault_id = env.register(MockStakeVaultWithMultiplier, ()); // 120 + let reward_pool_id = env.register(MockRewardPoolTransfer, ()); + let rp_client = MockRewardPoolTransferClient::new(&env, &reward_pool_id); + rp_client.set_token(&token_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &token_id, &reward_pool_id, &stake_vault_id); + + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 1000; + let metadata_hash = BytesN::from_array(&env, &[110u8; 32]); + + let fee = (reward_amount * 15) / 100; + let base = reward_amount - fee; + let boosted = (base * 120) / 100; + let boost_delta = boosted - base; + + mint_tokens(&env, &token_id, &employer, &reward_amount); + mint_tokens(&env, &token_id, &reward_pool_id, &boost_delta); + + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + client.batch_review_submissions(&employer, &quest_id, &learners); + + assert_eq!(token_balance(&env, &token_id, &learner), boosted); + // reward_pool started with boost_delta, received fee, gave away boost_delta + assert_eq!(token_balance(&env, &token_id, &reward_pool_id), fee); + + let submission = client.get_submission(&learner, &quest_id).unwrap(); + assert_eq!(submission.status, SubmissionStatus::Approved); +} + +/// Batch approval with a 200× multiplier vault: larger boost delta. +#[test] +fn test_batch_review_with_200_multiplier() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuestEngineContract, ()); + let client = QuestEngineContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let stake_vault_id = env.register(MockStakeVault200, ()); // 200 + let reward_pool_id = env.register(MockRewardPoolTransfer, ()); + let rp_client = MockRewardPoolTransferClient::new(&env, &reward_pool_id); + rp_client.set_token(&token_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &token_id, &reward_pool_id, &stake_vault_id); + + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 1000; + let metadata_hash = BytesN::from_array(&env, &[111u8; 32]); + + let fee = (reward_amount * 15) / 100; + let base = reward_amount - fee; + let boosted = (base * 200) / 100; + let boost_delta = boosted - base; + + mint_tokens(&env, &token_id, &employer, &reward_amount); + mint_tokens(&env, &token_id, &reward_pool_id, &boost_delta); + + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + client.batch_review_submissions(&employer, &quest_id, &learners); + + assert_eq!(token_balance(&env, &token_id, &learner), boosted); + assert_eq!(token_balance(&env, &token_id, &reward_pool_id), fee); +} + +/// Batch approval with an 80× (penalty) multiplier vault: learner receives +/// less than base; shortfall flows into reward pool alongside the fee. +#[test] +fn test_batch_review_with_80_multiplier() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuestEngineContract, ()); + let client = QuestEngineContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + let stake_vault_id = env.register(MockStakeVault80, ()); // 80 + let reward_pool_id = env.register(MockRewardPool, ()); + + let admin = Address::generate(&env); + client.initialize(&admin, &token_id, &reward_pool_id, &stake_vault_id); + + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 1000; + let metadata_hash = BytesN::from_array(&env, &[112u8; 32]); + + let fee = (reward_amount * 15) / 100; + let base = reward_amount - fee; + let penalised = (base * 80) / 100; + let penalty = base - penalised; + + mint_tokens(&env, &token_id, &employer, &reward_amount); + + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + client.batch_review_submissions(&employer, &quest_id, &learners); + + assert_eq!(token_balance(&env, &token_id, &learner), penalised); + // reward_pool (no-op mock) received fee + penalty from escrow + assert_eq!( + token_balance(&env, &token_id, &reward_pool_id), + fee + penalty + ); +} + +/// Batch approval with mixed multiplier tiers: learner A gets 120×, learner B +/// gets 80×, learner C gets 100× (baseline). Each must receive the correct +/// individual payout. +/// +/// This test uses `MockStakeVaultPerLearner` so each learner has its own tier. +#[test] +fn test_batch_review_mixed_multiplier_tiers() { + let env = Env::default(); + env.mock_all_auths(); + + // Register the per-learner vault and configure tiers. + let stake_vault_id = env.register(MockStakeVaultPerLearner, ()); + let sv_client = MockStakeVaultPerLearnerClient::new(&env, &stake_vault_id); + + let contract_id = env.register(QuestEngineContract, ()); + let client = QuestEngineContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + // Reward pool that actually transfers tokens (needed for learner_a's boost). + let reward_pool_id = env.register(MockRewardPoolTransfer, ()); + let rp_client = MockRewardPoolTransferClient::new(&env, &reward_pool_id); + rp_client.set_token(&token_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &token_id, &reward_pool_id, &stake_vault_id); + + let employer = Address::generate(&env); + let learner_a = Address::generate(&env); // 120× — boost + let learner_b = Address::generate(&env); // 80× — penalty + let learner_c = Address::generate(&env); // 100× — baseline + + sv_client.set_multiplier(&learner_a, &120u32); + sv_client.set_multiplier(&learner_b, &80u32); + sv_client.set_multiplier(&learner_c, &100u32); + + let reward_amount: i128 = 1000; + let metadata_hash = BytesN::from_array(&env, &[113u8; 32]); + + // Fee/payout math for each learner. + let fee = (reward_amount * 15) / 100; // 150 + let base = reward_amount - fee; // 850 + + let expected_a = (base * 120) / 100; // 1020 (boost delta = 170) + let expected_b = (base * 80) / 100; // 680 (penalty = 170) + let expected_c = base; // 850 (no change) + + let boost_delta_a = expected_a - base; // 170 + + // Three separate quests (one per learner) so the escrow per-quest is correct. + mint_tokens(&env, &token_id, &employer, &(reward_amount * 3)); + // Seed the reward pool with enough to cover learner_a's boost. + mint_tokens(&env, &token_id, &reward_pool_id, &boost_delta_a); + + let qid_a = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + let qid_b = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + let qid_c = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + + client.submit_proof(&learner_a, &qid_a, &metadata_hash); + client.submit_proof(&learner_b, &qid_b, &metadata_hash); + client.submit_proof(&learner_c, &qid_c, &metadata_hash); + + // Approve each learner via separate batch calls (one learner per quest). + let mut la = soroban_sdk::Vec::new(&env); + la.push_back(learner_a.clone()); + client.batch_review_submissions(&employer, &qid_a, &la); + + let mut lb = soroban_sdk::Vec::new(&env); + lb.push_back(learner_b.clone()); + client.batch_review_submissions(&employer, &qid_b, &lb); + + let mut lc = soroban_sdk::Vec::new(&env); + lc.push_back(learner_c.clone()); + client.batch_review_submissions(&employer, &qid_c, &lc); + + assert_eq!(token_balance(&env, &token_id, &learner_a), expected_a); + assert_eq!(token_balance(&env, &token_id, &learner_b), expected_b); + assert_eq!(token_balance(&env, &token_id, &learner_c), expected_c); + + // Reward pool: started with boost_delta_a, received 3 fees, paid out boost_delta_a. + // Also received penalty from learner_b = base - expected_b = 170. + // Net: 3 * fee + penalty_b = 450 + 170 = 620. + let expected_pool = 3 * fee + (base - expected_b); // 450 + 170 = 620 + assert_eq!( + token_balance(&env, &token_id, &reward_pool_id), + expected_pool + ); +} + +/// Batch approval must fail deterministically when the reward pool cannot +/// cover the boost delta for a learner with a multiplier > 100. +#[test] +#[should_panic] +fn test_batch_review_fails_when_pool_cannot_cover_boost() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(QuestEngineContract, ()); + let client = QuestEngineContractClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + + // 120× vault but pool has no funds — transfer will fail. + let stake_vault_id = env.register(MockStakeVaultWithMultiplier, ()); + let reward_pool_id = env.register(MockRewardPoolTransfer, ()); + let rp_client = MockRewardPoolTransferClient::new(&env, &reward_pool_id); + rp_client.set_token(&token_id); + // Intentionally DO NOT mint boost_delta to reward_pool. + + let admin = Address::generate(&env); + client.initialize(&admin, &token_id, &reward_pool_id, &stake_vault_id); + + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 1000; + let metadata_hash = BytesN::from_array(&env, &[114u8; 32]); + + mint_tokens(&env, &token_id, &employer, &reward_amount); + + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + // Should panic because pool has zero balance. + client.batch_review_submissions(&employer, &quest_id, &learners); +} + +/// Batch approval should panic when the contract is paused. +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_batch_review_paused_panics() { + let (env, client, token_id, _reward_pool, admin, _stake_vault_id) = setup(); + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 500; + let metadata_hash = BytesN::from_array(&env, &[115u8; 32]); + + mint_tokens(&env, &token_id, &employer, &reward_amount); + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + client.set_pause(&admin, &true); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + client.batch_review_submissions(&employer, &quest_id, &learners); +} + +/// Batch approval should panic when a submission is already reviewed. +#[test] +#[should_panic(expected = "Submission is not pending review")] +fn test_batch_review_already_reviewed_panics() { + let (env, client, token_id, _reward_pool, _admin, _stake_vault_id) = setup(); + let employer = Address::generate(&env); + let learner = Address::generate(&env); + let reward_amount: i128 = 500; + let metadata_hash = BytesN::from_array(&env, &[116u8; 32]); + + mint_tokens(&env, &token_id, &employer, &reward_amount); + let quest_id = client.create_build_quest(&employer, &reward_amount, &metadata_hash); + client.submit_proof(&learner, &quest_id, &metadata_hash); + + let mut learners = soroban_sdk::Vec::new(&env); + learners.push_back(learner.clone()); + client.batch_review_submissions(&employer, &quest_id, &learners); + + // Second batch on the same (now-Approved) submission should panic. + // Need a new quest because the first is now inactive, but the learner + // submission is already Approved – re-registering same learner+quest + // key will surface the same panic. + client.batch_review_submissions(&employer, &quest_id, &learners); +}