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
41 changes: 41 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,47 @@ impl NesteraContract {
rewards::redemption::redeem_points(&env, user, amount)
}

/// Sets the token contract address used for distributing native token rewards (admin only).
pub fn set_reward_token(env: Env, admin: Address, token: Address) -> Result<(), SavingsError> {
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(SavingsError::Unauthorized)?;
stored_admin.require_auth();
if admin != stored_admin {
return Err(SavingsError::Unauthorized);
}
env.storage()
.instance()
.set(&rewards::storage_types::RewardsDataKey::RewardToken, &token);
Ok(())
}

/// Converts a user's accumulated points into claimable token rewards.
/// Must be called before claim_rewards.
pub fn convert_points_to_tokens(
env: Env,
user: Address,
points_to_convert: u128,
tokens_per_point: i128,
) -> Result<i128, SavingsError> {
user.require_auth();
rewards::storage::convert_points_to_tokens(&env, user, points_to_convert, tokens_per_point)
}

/// Claims all unclaimed token rewards, transferring native tokens to the user.
/// Prevents double-claiming and emits RewardsClaimed event.
pub fn claim_rewards(env: Env, user: Address) -> Result<i128, SavingsError> {
user.require_auth();
ensure_not_paused(&env)?;
crate::security::acquire_reentrancy_guard(&env)?;
let contract_address = env.current_contract_address();
let res = rewards::storage::claim_rewards(&env, user, contract_address);
crate::security::release_reentrancy_guard(&env);
res
}

// ========== AutoSave Functions ==========

/// Creates a new AutoSave schedule for recurring Flexi deposits
Expand Down
19 changes: 19 additions & 0 deletions contracts/src/rewards/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,25 @@ pub fn emit_points_redeemed(env: &Env, user: Address, amount: u128) {
);
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RewardsClaimed {
pub user: Address,
pub amount: i128,
}

/// Emits a RewardsClaimed event.
pub fn emit_rewards_claimed(env: &Env, user: Address, amount: i128) {
let event = RewardsClaimed {
user: user.clone(),
amount,
};
env.events().publish(
(symbol_short!("rewards"), symbol_short!("claimed"), user),
event,
);
}

/// Emits a StreakUpdated event.
pub fn emit_streak_updated(env: &Env, user: Address, streak: u32) {
let event = StreakUpdated {
Expand Down
99 changes: 97 additions & 2 deletions contracts/src/rewards/storage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use super::storage_types::{RewardsDataKey, UserRewards};
use crate::errors::SavingsError;
use crate::rewards::config::get_rewards_config;
use crate::rewards::events::{emit_bonus_awarded, emit_points_awarded, emit_streak_updated};
use soroban_sdk::{Address, Env, Symbol};
use crate::rewards::events::{emit_bonus_awarded, emit_points_awarded, emit_rewards_claimed, emit_streak_updated};
use soroban_sdk::{Address, Env, Symbol, token};

/// Duration threshold for long-lock bonus eligibility (in seconds).
pub const LONG_LOCK_BONUS_THRESHOLD_SECS: u64 = 180 * 24 * 60 * 60;
Expand Down Expand Up @@ -31,6 +31,8 @@ pub fn get_user_rewards(env: &Env, user: Address) -> UserRewards {
last_action_timestamp: 0,
daily_points_earned: 0,
last_reward_day: 0,
claimed_tokens: 0,
unclaimed_tokens: 0,
}
}
}
Expand All @@ -56,6 +58,8 @@ pub fn initialize_user_rewards(env: &Env, user: Address) -> Result<(), SavingsEr
last_action_timestamp: env.ledger().timestamp(),
daily_points_earned: 0,
last_reward_day: env.ledger().timestamp() / 86400,
claimed_tokens: 0,
unclaimed_tokens: 0,
};

// Now this function can find save_user_rewards because they are in the same file
Expand Down Expand Up @@ -224,6 +228,97 @@ pub fn award_deposit_points(env: &Env, user: Address, amount: i128) -> Result<()
Ok(())
}

/// Claims all unclaimed token rewards for a user, transferring native tokens from the contract.
///
/// # Arguments
/// * `env` - Contract environment
/// * `user` - User address claiming rewards
/// * `contract_address` - This contract's own address (for token transfer)
///
/// # Returns
/// * `Ok(i128)` - Amount of tokens claimed
/// * `Err(SavingsError)` if no rewards, token not configured, or arithmetic error
///
/// # Safety
/// * Prevents double-claiming by zeroing unclaimed_tokens before transfer
/// * Uses checked arithmetic
/// * Emits RewardsClaimed event on success
pub fn claim_rewards(
env: &Env,
user: Address,
contract_address: Address,
) -> Result<i128, SavingsError> {
let mut rewards = get_user_rewards(env, user.clone());

if rewards.unclaimed_tokens == 0 {
return Err(SavingsError::InsufficientBalance);
}

let amount = rewards.unclaimed_tokens;

// Get the reward token address
let token_address: Address = env
.storage()
.instance()
.get(&RewardsDataKey::RewardToken)
.ok_or(SavingsError::InternalError)?;

// Zero out unclaimed before transfer (prevent double-claim)
rewards.unclaimed_tokens = 0;
rewards.claimed_tokens = rewards
.claimed_tokens
.checked_add(amount)
.ok_or(SavingsError::Overflow)?;
save_user_rewards(env, user.clone(), &rewards);

// Transfer tokens from contract to user
let token_client = token::Client::new(env, &token_address);
token_client.transfer(&contract_address, &user, &amount);

emit_rewards_claimed(env, user, amount);
Ok(amount)
}

/// Converts accumulated points to claimable token rewards at a given rate.
///
/// # Arguments
/// * `env` - Contract environment
/// * `user` - User address
/// * `points_to_convert` - Number of points to convert
/// * `tokens_per_point` - Token amount per point (in token's smallest unit)
pub fn convert_points_to_tokens(
env: &Env,
user: Address,
points_to_convert: u128,
tokens_per_point: i128,
) -> Result<i128, SavingsError> {
if points_to_convert == 0 || tokens_per_point <= 0 {
return Err(SavingsError::InvalidAmount);
}

let mut rewards = get_user_rewards(env, user.clone());

if rewards.total_points < points_to_convert {
return Err(SavingsError::InsufficientBalance);
}

let token_amount = (points_to_convert as i128)
.checked_mul(tokens_per_point)
.ok_or(SavingsError::Overflow)?;

rewards.total_points = rewards
.total_points
.checked_sub(points_to_convert)
.ok_or(SavingsError::Overflow)?;
rewards.unclaimed_tokens = rewards
.unclaimed_tokens
.checked_add(token_amount)
.ok_or(SavingsError::Overflow)?;

save_user_rewards(env, user, &rewards);
Ok(token_amount)
}

/// Awards bonus points for long lock plans when duration exceeds the configured threshold.
pub fn award_long_lock_bonus(
env: &Env,
Expand Down
7 changes: 6 additions & 1 deletion contracts/src/rewards/storage_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ pub struct UserRewards {
// Anti-farming tracking
pub daily_points_earned: u128, // Points earned today
pub last_reward_day: u64, // Last day rewards were earned (ledger day)

// Token rewards tracking
pub claimed_tokens: i128, // Cumulative tokens already claimed
pub unclaimed_tokens: i128, // Tokens available to claim
}

#[contracttype]
pub enum RewardsDataKey {
Config,
UserLedger(Address),
AllUsers, // Tracks all users with rewards for ranking
AllUsers, // Tracks all users with rewards for ranking
RewardToken, // The token contract address used for distributing rewards
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -183,6 +191,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -786,6 +794,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down Expand Up @@ -833,6 +849,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -880,6 +904,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -611,6 +619,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -183,6 +191,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,14 @@
"durability": "persistent",
"val": {
"map": [
{
"key": {
"symbol": "claimed_tokens"
},
"val": {
"i128": "0"
}
},
{
"key": {
"symbol": "current_streak"
Expand Down Expand Up @@ -771,6 +779,14 @@
"val": {
"u128": "0"
}
},
{
"key": {
"symbol": "unclaimed_tokens"
},
"val": {
"i128": "0"
}
}
]
}
Expand Down
Loading
Loading