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
51 changes: 51 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod lock;
pub mod rewards;
mod storage_types;
pub mod strategy;
pub mod token;
pub mod treasury;
mod ttl;
mod upgrade;
Expand Down Expand Up @@ -165,6 +166,10 @@ impl NesteraContract {
env.storage().instance().set(&DataKey::Initialized, &true);
env.storage().persistent().set(&DataKey::Paused, &false);

// Initialize native protocol token (supply assigned to admin/deployer as treasury)
token::initialize_token(&env, admin.clone(), 1_000_000_000_0000000)
.unwrap_or_else(|e| panic_with_error!(&env, e));

// Extend TTL for paused state
ttl::extend_config_ttl(&env, &DataKey::Paused);

Expand Down Expand Up @@ -766,6 +771,11 @@ impl NesteraContract {
.unwrap_or(0)
}

/// Returns the native protocol token metadata (name, symbol, decimals, total_supply, treasury).
pub fn get_token_metadata(env: Env) -> Result<token::TokenMetadata, SavingsError> {
token::get_token_metadata(&env)
}

// ========== Rewards Functions ==========

pub fn init_rewards_config(
Expand Down Expand Up @@ -875,6 +885,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
101 changes: 99 additions & 2 deletions contracts/src/rewards/storage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
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::{token, Address, Env, Symbol};

/// 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 +33,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 +60,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 +230,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
}
2 changes: 2 additions & 0 deletions contracts/src/storage_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ pub enum DataKey {
LockRate(u64),
/// Maps (plan_type, plan_id) to disabled status
DisabledStrategy(PlanType, u64),
/// Stores the native protocol token metadata (name, symbol, decimals, supply, treasury)
TokenMetadata,
}

/// Payload structure that the admin signs off-chain
Expand Down
65 changes: 65 additions & 0 deletions contracts/src/token.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//! Native protocol token metadata and initialization for Nestera (#374).

use crate::errors::SavingsError;
use crate::storage_types::DataKey;
use soroban_sdk::{contracttype, symbol_short, Address, Env, String};

/// Metadata for the Nestera native protocol token.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TokenMetadata {
pub name: String,
pub symbol: String,
pub decimals: u32,
pub total_supply: i128,
pub treasury: Address,
}

/// Initializes the protocol token metadata and assigns total supply to the treasury.
///
/// Can only be called once. Subsequent calls return `ConfigAlreadyInitialized`.
///
/// # Arguments
/// * `env` - Contract environment
/// * `treasury` - Address that receives the initial total supply
/// * `total_supply` - Total token supply (in smallest unit, e.g. stroops)
pub fn initialize_token(
env: &Env,
treasury: Address,
total_supply: i128,
) -> Result<(), SavingsError> {
if env.storage().instance().has(&DataKey::TokenMetadata) {
return Err(SavingsError::ConfigAlreadyInitialized);
}

if total_supply <= 0 {
return Err(SavingsError::InvalidAmount);
}

let metadata = TokenMetadata {
name: String::from_str(env, "Nestera"),
symbol: String::from_str(env, "NST"),
decimals: 7,
total_supply,
treasury: treasury.clone(),
};

env.storage()
.instance()
.set(&DataKey::TokenMetadata, &metadata);

env.events().publish(
(symbol_short!("token"), symbol_short!("init"), treasury),
total_supply,
);

Ok(())
}

/// Returns the stored token metadata.
pub fn get_token_metadata(env: &Env) -> Result<TokenMetadata, SavingsError> {
env.storage()
.instance()
.get(&DataKey::TokenMetadata)
.ok_or(SavingsError::InternalError)
}
Loading
Loading