Skip to content
Open
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
9 changes: 6 additions & 3 deletions .github/workflows/contract-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 38 additions & 17 deletions contracts/earn-quest/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -208,7 +227,6 @@ fn refund_remaining(env: &Env, quest_id: &Symbol) -> Result<i128, Error> {
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();
Expand All @@ -217,7 +235,7 @@ fn refund_remaining(env: &Env, quest_id: &Symbol) -> Result<i128, Error> {
// 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);

Expand Down Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions contracts/earn-quest/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions contracts/earn-quest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand All @@ -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(())
}
Expand Down
22 changes: 11 additions & 11 deletions contracts/earn-quest/src/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -68,10 +68,10 @@ impl Oracle {
request: &PriceFeedRequest,
) -> Result<AggregatedPrice, Error> {
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));
Expand Down Expand Up @@ -165,16 +165,16 @@ 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 {
return Err(Error::InvalidOracleConfig);
}

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

Expand All @@ -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))))
}
}

Expand Down
24 changes: 16 additions & 8 deletions contracts/earn-quest/src/quest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions contracts/earn-quest/src/reputation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const LEVEL_5_XP: u64 = 1500;
pub fn award_xp(env: &Env, user: &Address, xp_amount: u64) -> Result<UserCore, Error> {
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;
Expand Down
2 changes: 1 addition & 1 deletion contracts/earn-quest/src/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading