Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
12 changes: 7 additions & 5 deletions contracts/earn-quest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 | ✅ |
Expand All @@ -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
```

Expand Down
38 changes: 18 additions & 20 deletions contracts/earn-quest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -484,15 +482,15 @@ impl EarnQuestContract {
&quest_id,
&quest.reward_asset,
&submitter,
quest.reward_amount,
amount,
)?;

events::reward_claimed(
&env,
quest_id.clone(),
submitter.clone(),
quest.reward_asset,
quest.reward_amount,
amount,
);

reputation::award_xp(&env, &submitter, 100)?;
Expand Down
22 changes: 20 additions & 2 deletions contracts/earn-quest/src/submission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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<i128, Error> {
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.
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion contracts/earn-quest/src/test_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions contracts/earn-quest/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions contracts/earn-quest/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
4 changes: 2 additions & 2 deletions contracts/earn-quest/tests/test_batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions contracts/earn-quest/tests/test_data_structures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 2 additions & 2 deletions contracts/earn-quest/tests/test_escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

// ══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion contracts/earn-quest/tests/test_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
48 changes: 44 additions & 4 deletions contracts/earn-quest/tests/test_payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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");
}
4 changes: 2 additions & 2 deletions contracts/earn-quest/tests/test_reentrancy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion contracts/earn-quest/tests/test_reputation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading