diff --git a/README.md b/README.md index 8651516f..82f0d382 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ The Soroban smart contract includes these conceptual modules: register_task(id, reward_asset, amount, verifier) submit_proof(id, proof_ref) approve(id, address, amount) -claim_reward(id) +claim_reward(id, amount) get_user_stats(address) get_task(id) ``` diff --git a/contracts/earn-quest/README.md b/contracts/earn-quest/README.md index 13a58100..146be774 100644 --- a/contracts/earn-quest/README.md +++ b/contracts/earn-quest/README.md @@ -100,15 +100,16 @@ pub fn claim_reward( env: Env, quest_id: Symbol, submitter: Address, + amount: i128, ) -> Result<(), Error> ``` **Flow:** 1. User authentication -2. Validate submission is approved -3. Check not already claimed -4. Transfer reward tokens -5. Update submission status to Paid +2. Validate submission is approved or partially paid +3. Validate requested amount against remaining reward balance +4. Transfer the requested amount from escrow +5. Update submission status to `PartiallyPaid` or `Paid` 6. Emit claim event ### ✅ Comprehensive Error Handling @@ -198,6 +199,7 @@ Optimized for deployment to Stellar network. | Asset validation | ✅ | | Balance checking | ✅ | | Claim reward function | ✅ | +| Partial claims supported | ✅ | | Duplicate prevention | ✅ | | Event emission | ✅ | | Comprehensive tests | ✅ | @@ -222,7 +224,7 @@ client.submit_proof(&quest_id, &user, &proof_hash); client.approve_submission(&quest_id, &user, &verifier); // 4. User claims reward -client.claim_reward(&quest_id, &user); +client.claim_reward(&quest_id, &user, &100); // ✅ Tokens transferred to user's account ``` diff --git a/contracts/earn-quest/src/lib.rs b/contracts/earn-quest/src/lib.rs index 852733f3..f9c8f691 100644 --- a/contracts/earn-quest/src/lib.rs +++ b/contracts/earn-quest/src/lib.rs @@ -442,18 +442,12 @@ impl EarnQuestContract { submission::approve_submissions_batch(&env, &verifier, &submissions) } - /// Claims the reward for an approved submission. - /// - /// # Arguments - /// - /// * `env` - The environment. - /// * `quest_id` - The symbol of the quest. - /// * `submitter` - The address of the user claiming the reward. - /// - /// # Security - /// - /// Implements non-reentrancy and CEI (Checks-Effects-Interactions) patterns. - pub fn claim_reward(env: Env, quest_id: Symbol, submitter: Address) -> Result<(), Error> { + pub fn claim_reward( + env: Env, + quest_id: Symbol, + submitter: Address, + amount: i128, + ) -> Result<(), Error> { security::require_not_paused(&env)?; security::nonreentrant_enter(&env)?; submitter.require_auth(); @@ -464,14 +458,18 @@ impl EarnQuestContract { // Validate using pre-read data submission::validate_claim_data(&quest, &submission)?; + submission::validate_claim_amount(&quest, &submission, amount)?; - // CEI: flip the submission to Paid and increment claims BEFORE the - // external token transfer. If a malicious token re-enters during - // the transfer the AlreadyClaimed check in validate_claim will - // reject the second attempt even before the reentrancy guard kicks - // in, giving us defence in depth. + // CEI: record claim status and increment claims before the external + // 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.status = types::SubmissionStatus::Paid; + submission.claimed_amount += amount; + submission.status = if submission.claimed_amount == quest.reward_amount { + types::SubmissionStatus::Paid + } else { + types::SubmissionStatus::PartiallyPaid + }; storage::set_submission(&env, &quest_id, &submitter, &submission); // Increment claims: directly update quest to avoid extra read @@ -484,7 +482,7 @@ impl EarnQuestContract { &quest_id, &quest.reward_asset, &submitter, - quest.reward_amount, + amount, )?; events::reward_claimed( @@ -492,7 +490,7 @@ impl EarnQuestContract { quest_id.clone(), submitter.clone(), quest.reward_asset, - quest.reward_amount, + amount, ); reputation::award_xp(&env, &submitter, 100)?; diff --git a/contracts/earn-quest/src/submission.rs b/contracts/earn-quest/src/submission.rs index 29c1adf6..f5136dfb 100644 --- a/contracts/earn-quest/src/submission.rs +++ b/contracts/earn-quest/src/submission.rs @@ -107,6 +107,7 @@ pub fn reveal_submission( submitter: submitter.clone(), proof_hash: proof_hash.clone(), status: SubmissionStatus::Pending, + claimed_amount: 0, timestamp: env.ledger().timestamp(), }; @@ -155,6 +156,7 @@ pub fn submit_proof( submitter: submitter.clone(), proof_hash: proof_hash.clone(), status: SubmissionStatus::Pending, + claimed_amount: 0, timestamp: env.ledger().timestamp(), }; @@ -215,6 +217,22 @@ pub fn approve_submission( Ok(()) } +/// Validates a claim amount against the remaining reward for a submission. +pub fn validate_claim_amount( + quest: &crate::types::Quest, + submission: &crate::types::Submission, + amount: i128, +) -> Result { + validation::validate_reward_amount(amount)?; + + let remaining = quest.reward_amount - submission.claimed_amount; + if amount > remaining { + return Err(Error::InvalidClaimAmount); + } + + Ok(remaining) +} + /// Core claim validation that operates on already-fetched data. /// /// This function performs the necessary checks to ensure a reward claim is valid. @@ -235,12 +253,12 @@ pub fn validate_claim_data( quest: &crate::types::Quest, submission: &crate::types::Submission, ) -> Result<(), Error> { - // Check if already claimed + // Check if already fully claimed if submission.status == SubmissionStatus::Paid { return Err(Error::AlreadyClaimed); } - // Validate status transition: Approved -> Paid + // Validate status transition: Approved/PartiallyPaid -> Paid or PartiallyPaid validation::validate_submission_status_transition( &submission.status, &SubmissionStatus::Paid, diff --git a/contracts/earn-quest/src/test_stats.rs b/contracts/earn-quest/src/test_stats.rs index 4605250d..3c493e25 100644 --- a/contracts/earn-quest/src/test_stats.rs +++ b/contracts/earn-quest/src/test_stats.rs @@ -96,7 +96,7 @@ fn full_lifecycle( 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); + client.claim_reward(&quest_id, submitter, &reward_amount); quest_id } diff --git a/contracts/earn-quest/src/types.rs b/contracts/earn-quest/src/types.rs index 12961f44..e70126f8 100644 --- a/contracts/earn-quest/src/types.rs +++ b/contracts/earn-quest/src/types.rs @@ -60,7 +60,7 @@ pub struct Submission { pub proof_hash: BytesN<32>, /// Current status of the submission. pub status: SubmissionStatus, - /// Unix timestamp when the submission was created. + pub claimed_amount: i128, pub timestamp: u64, } @@ -72,7 +72,7 @@ pub enum SubmissionStatus { Pending, /// Approved by the verifier, reward can be claimed. Approved, - /// Rejected by the verifier. + PartiallyPaid, Rejected, /// Reward has been successfully claimed. Paid, diff --git a/contracts/earn-quest/src/validation.rs b/contracts/earn-quest/src/validation.rs index e1689af3..ea782f77 100644 --- a/contracts/earn-quest/src/validation.rs +++ b/contracts/earn-quest/src/validation.rs @@ -301,7 +301,10 @@ pub fn validate_submission_status_transition( let valid = match (from, to) { (SubmissionStatus::Pending, SubmissionStatus::Approved) => true, (SubmissionStatus::Pending, SubmissionStatus::Rejected) => true, + (SubmissionStatus::Approved, SubmissionStatus::PartiallyPaid) => true, (SubmissionStatus::Approved, SubmissionStatus::Paid) => true, + (SubmissionStatus::PartiallyPaid, SubmissionStatus::PartiallyPaid) => true, + (SubmissionStatus::PartiallyPaid, SubmissionStatus::Paid) => true, _ => false, }; diff --git a/contracts/earn-quest/tests/test_batch.rs b/contracts/earn-quest/tests/test_batch.rs index cacb7f8f..3194b0d1 100644 --- a/contracts/earn-quest/tests/test_batch.rs +++ b/contracts/earn-quest/tests/test_batch.rs @@ -316,8 +316,8 @@ fn test_approve_submissions_batch_success() { client.approve_submissions_batch(&verifier, &submissions); // Claim both rewards - client.claim_reward(&symbol_short!("AQ1"), &submitter1); - client.claim_reward(&symbol_short!("AQ2"), &submitter2); + client.claim_reward(&symbol_short!("AQ1"), &submitter1, &100); + client.claim_reward(&symbol_short!("AQ2"), &submitter2, &200); assert_eq!(token_client.balance(&submitter1), 100); assert_eq!(token_client.balance(&submitter2), 200); diff --git a/contracts/earn-quest/tests/test_data_structures.rs b/contracts/earn-quest/tests/test_data_structures.rs index f044126a..4a30e5a2 100644 --- a/contracts/earn-quest/tests/test_data_structures.rs +++ b/contracts/earn-quest/tests/test_data_structures.rs @@ -160,7 +160,7 @@ fn test_award_xp_only_updates_user_core() { let proof = BytesN::from_array(&env, &[1u8; 32]); client.submit_proof(&quest_id, &submitter, &proof); client.approve_submission(&quest_id, &submitter, &verifier); - client.claim_reward(&quest_id, &submitter); + client.claim_reward(&quest_id, &submitter, &100); // UserCore should have XP let stats = client.get_user_stats(&submitter); @@ -398,7 +398,7 @@ fn test_full_lifecycle_with_split_structs() { let proof = 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); + client.claim_reward(&quest_id, &submitter, &100); // Grant badge (writes UserBadges only) client.grant_badge(&admin, &submitter, &Badge::rookie(&env)); diff --git a/contracts/earn-quest/tests/test_escrow.rs b/contracts/earn-quest/tests/test_escrow.rs index 0ea2e24d..3e5b1c6d 100644 --- a/contracts/earn-quest/tests/test_escrow.rs +++ b/contracts/earn-quest/tests/test_escrow.rs @@ -102,7 +102,7 @@ fn submit_proof(t: &TestEnv, quest_id: &Symbol, user: &Address) { fn complete_quest(t: &TestEnv, quest_id: &Symbol, user: &Address) { submit_proof(t, quest_id, user); t.contract.approve_submission(quest_id, user, &t.verifier); - t.contract.claim_reward(quest_id, user); + t.contract.claim_reward(quest_id, user, &1000_i128); } // ══════════════════════════════════════════════════════════════ @@ -413,7 +413,7 @@ fn test_topup_unblocks_approval_after_depletion() { // Now approval should succeed t.contract.approve_submission(&qid, &t.user_b, &t.verifier); - t.contract.claim_reward(&qid, &t.user_b); + t.contract.claim_reward(&qid, &t.user_b, &1000_i128); assert_eq!(t.token.balance(&t.user_b), 1_000); assert_eq!(t.contract.get_escrow_balance(&qid), 1_000); } diff --git a/contracts/earn-quest/tests/test_events.rs b/contracts/earn-quest/tests/test_events.rs index ba86827b..e2d61baf 100644 --- a/contracts/earn-quest/tests/test_events.rs +++ b/contracts/earn-quest/tests/test_events.rs @@ -106,7 +106,7 @@ fn test_full_quest_lifecycle_events() { assert_eq!(t_verifier, verifier); // --- STEP 4: CLAIM REWARD --- - client.claim_reward(&quest_id, &user); + client.claim_reward(&quest_id, &user, &reward_amount); let events = env.events().all(); diff --git a/contracts/earn-quest/tests/test_payout.rs b/contracts/earn-quest/tests/test_payout.rs index 3a9b41c8..3b107d1f 100644 --- a/contracts/earn-quest/tests/test_payout.rs +++ b/contracts/earn-quest/tests/test_payout.rs @@ -53,12 +53,52 @@ fn test_payout_success() { let pre_balance = token_client.balance(&submitter); assert_eq!(pre_balance, 0); - client.claim_reward(&quest_id, &submitter); + client.claim_reward(&quest_id, &submitter, &reward_amount); let post_balance = token_client.balance(&submitter); assert_eq!(post_balance, 100); } +#[test] +fn test_partial_claim_support() { + 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 token_contract_obj = env.register_stellar_asset_contract_v2(admin.clone()); + let token_contract = token_contract_obj.address(); + let token_admin_client = StellarAssetClient::new(&env, &token_contract); + let token_client = TokenClient::new(&env, &token_contract); + + let creator = Address::generate(&env); + let verifier = Address::generate(&env); + let submitter = Address::generate(&env); + let quest_id = symbol_short!("Q4"); + + client.register_quest( + &quest_id, + &creator, + &token_contract, + &100, + &verifier, + &10000, + ); + + let proof = BytesN::from_array(&env, &[1u8; 32]); + client.submit_proof(&quest_id, &submitter, &proof); + client.approve_submission(&quest_id, &submitter, &verifier); + + let first_claim = 40i128; + client.claim_reward(&quest_id, &submitter, &first_claim); + assert_eq!(token_client.balance(&submitter), first_claim); + + client.claim_reward(&quest_id, &submitter, &(100 - first_claim)); + assert_eq!(token_client.balance(&submitter), 100); +} + #[test] fn test_insufficient_balance() { let env = Env::default(); @@ -91,7 +131,7 @@ fn test_insufficient_balance() { client.approve_submission(&quest_id, &submitter, &verifier); // Claim should fail with InsufficientBalance - let res = client.try_claim_reward(&quest_id, &submitter); + let res = client.try_claim_reward(&quest_id, &submitter, &100); assert!( res.is_err(), "Expected claim to fail due to insufficient balance" @@ -131,9 +171,9 @@ fn test_double_claim_prevention() { client.approve_submission(&quest_id, &submitter, &verifier); // First claim - client.claim_reward(&quest_id, &submitter); + client.claim_reward(&quest_id, &submitter, &100); // Second claim should fail with AlreadyClaimed - let res = client.try_claim_reward(&quest_id, &submitter); + let res = client.try_claim_reward(&quest_id, &submitter, &100); assert!(res.is_err(), "Expected second claim to fail"); } diff --git a/contracts/earn-quest/tests/test_reentrancy.rs b/contracts/earn-quest/tests/test_reentrancy.rs index ae008c88..5c018ad0 100644 --- a/contracts/earn-quest/tests/test_reentrancy.rs +++ b/contracts/earn-quest/tests/test_reentrancy.rs @@ -123,7 +123,7 @@ impl EvilToken { .unwrap(); let client = EarnQuestContractClient::new(&env, &target); - client.claim_reward(&quest_id, &submitter); + client.claim_reward(&quest_id, &submitter, &100); } } @@ -183,7 +183,7 @@ fn malicious_token_cannot_double_claim_via_reentrancy() { // re-enters claim_reward. The reentrancy guard rejects the nested call; // that error bubbles up through try_transfer; the outer claim_reward // returns Err and the transaction reverts. - let result = contract.try_claim_reward(&quest_id, &submitter); + let result = contract.try_claim_reward(&quest_id, &submitter, &100); assert!( result.is_err(), "reentrant claim_reward must not succeed", diff --git a/contracts/earn-quest/tests/test_reputation.rs b/contracts/earn-quest/tests/test_reputation.rs index 1f6d1508..d8db1986 100644 --- a/contracts/earn-quest/tests/test_reputation.rs +++ b/contracts/earn-quest/tests/test_reputation.rs @@ -53,7 +53,7 @@ fn complete_quest( let proof = BytesN::from_array(env, &[1u8; 32]); client.submit_proof(&quest_id, submitter, &proof); client.approve_submission(&quest_id, submitter, verifier); - client.claim_reward(&quest_id, submitter); + client.claim_reward(&quest_id, submitter, &reward_amount); } #[test]