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
2 changes: 2 additions & 0 deletions contracts/contracts/staking/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

[workspace]
51 changes: 41 additions & 10 deletions contracts/contracts/staking/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ impl StakingContract {
let mut info = Self::get_or_default_stake(&env, &artist);
info.pending_rewards = info
.pending_rewards
.checked_add(Self::calculate_accrued(&env, &info))
.checked_add(Self::calculate_accrued_checked(&env, &info)?)
.ok_or(Error::Overflow)?;

client.transfer(&artist, &env.current_contract_address(), &amount);
Expand Down Expand Up @@ -121,7 +121,7 @@ impl StakingContract {

info.pending_rewards = info
.pending_rewards
.checked_add(Self::calculate_accrued(&env, &info))
.checked_add(Self::calculate_accrued_checked(&env, &info)?)
.ok_or(Error::Overflow)?;

info.amount -= amount;
Expand All @@ -146,7 +146,7 @@ impl StakingContract {
);

let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0);
env.storage().instance().set(&DataKey::TotalStaked, &(total.saturating_sub(amount)));
env.storage().instance().set(&DataKey::TotalStaked, &(total.checked_sub(amount).ok_or(Error::Overflow)?));

env.events().publish((symbol_short!("unstaked"), artist.clone()), (amount, available_at));
Ok(())
Expand Down Expand Up @@ -181,7 +181,7 @@ impl StakingContract {
.get(&DataKey::Stake(artist.clone()))
.ok_or(Error::NoStake)?;

let accrued = Self::calculate_accrued(&env, &info);
let accrued = Self::calculate_accrued_checked(&env, &info)?;
let total_rewards = info.pending_rewards.checked_add(accrued).ok_or(Error::Overflow)?;

if total_rewards == 0 {
Expand Down Expand Up @@ -228,20 +228,25 @@ impl StakingContract {
.get(&DataKey::Stake(artist.clone()))
.ok_or(Error::NoStake)?;

let slash_amount = (info.amount * SLASH_RATE_BPS) / BPS_DENOM;
let slash_amount = info
.amount
.checked_mul(SLASH_RATE_BPS)
.ok_or(Error::Overflow)?
.checked_div(BPS_DENOM)
.ok_or(Error::Overflow)?;

let token = Self::get_token(&env);
let client = token::Client::new(&env, &token);
client.transfer(&env.current_contract_address(), &admin, &slash_amount);

info.amount -= slash_amount;
info.amount = info.amount.checked_sub(slash_amount).ok_or(Error::Overflow)?;
info.pending_rewards = 0;
info.since_ledger = env.ledger().sequence();
env.storage().persistent().set(&DataKey::Stake(artist.clone()), &info);
env.storage().persistent().set(&DataKey::Slashed(artist.clone()), &true);

let total: i128 = env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0);
env.storage().instance().set(&DataKey::TotalStaked, &(total.saturating_sub(slash_amount)));
env.storage().instance().set(&DataKey::TotalStaked, &(total.checked_sub(slash_amount).ok_or(Error::Overflow)?));

env.events().publish((symbol_short!("slashed"), artist.clone()), slash_amount);
Ok(slash_amount)
Expand Down Expand Up @@ -269,10 +274,21 @@ impl StakingContract {
env.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0)
}

pub fn reward_rate_bps(_env: Env) -> i128 {
REWARD_RATE_BPS
}

pub fn cooldown_ledgers(_env: Env) -> u32 {
COOLDOWN_LEDGERS
}

pub fn pending_rewards(env: Env, artist: Address) -> i128 {
match env.storage().persistent().get::<DataKey, StakeInfo>(&DataKey::Stake(artist)) {
None => 0,
Some(i) => i.pending_rewards + Self::calculate_accrued(&env, &i),
Some(i) => i
.pending_rewards
.checked_add(Self::calculate_accrued(&env, &i))
.unwrap_or(i128::MAX),
Comment on lines 285 to +291
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp overflow before returning pending_rewards().

This unwrap_or(i128::MAX) only protects the final addition. Self::calculate_accrued(&env, &i) still maps calculate_accrued_checked() errors to 0 via Line 343, so the view can under-report the same position that stake, unstake, and claim_rewards now reject with Error::Overflow.

Keep the read path consistent with the checked write path
 pub fn pending_rewards(env: Env, artist: Address) -> i128 {
     match env.storage().persistent().get::<DataKey, StakeInfo>(&DataKey::Stake(artist)) {
         None => 0,
-        Some(i) => i
-            .pending_rewards
-            .checked_add(Self::calculate_accrued(&env, &i))
-            .unwrap_or(i128::MAX),
+        Some(i) => Self::calculate_accrued_checked(&env, &i)
+            .ok()
+            .and_then(|accrued| i.pending_rewards.checked_add(accrued))
+            .unwrap_or(i128::MAX),
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn pending_rewards(env: Env, artist: Address) -> i128 {
match env.storage().persistent().get::<DataKey, StakeInfo>(&DataKey::Stake(artist)) {
None => 0,
Some(i) => i.pending_rewards + Self::calculate_accrued(&env, &i),
Some(i) => i
.pending_rewards
.checked_add(Self::calculate_accrued(&env, &i))
.unwrap_or(i128::MAX),
pub fn pending_rewards(env: Env, artist: Address) -> i128 {
match env.storage().persistent().get::<DataKey, StakeInfo>(&DataKey::Stake(artist)) {
None => 0,
Some(i) => Self::calculate_accrued_checked(&env, &i)
.ok()
.and_then(|accrued| i.pending_rewards.checked_add(accrued))
.unwrap_or(i128::MAX),
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/contracts/staking/src/lib.rs` around lines 285 - 291,
pending_rewards currently uses calculate_accrued (which hides overflow) and only
clamps the final addition; change it to call calculate_accrued_checked and if
that returns Err (overflow) return i128::MAX, otherwise perform pending =
i.pending_rewards.checked_add(accrued).unwrap_or(i128::MAX); this keeps the read
path (pending_rewards) consistent with the checked write path used by stake,
unstake, and claim_rewards and references DataKey::Stake,
StakeInfo.pending_rewards, calculate_accrued_checked, and pending_rewards.

}
}

Expand All @@ -286,9 +302,14 @@ impl StakingContract {
.ok_or(Error::NotInitialised)?;
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &new_admin);
env.events().publish((symbol_short!("adminset"), new_admin.clone()), ());
Ok(())
}

pub fn get_admin(env: Env) -> Result<Address, Error> {
env.storage().instance().get(&DataKey::Admin).ok_or(Error::NotInitialised)
}

fn assert_initialised(env: &Env) -> Result<(), Error> {
if !env.storage().instance().has(&DataKey::Admin) {
return Err(Error::NotInitialised);
Expand Down Expand Up @@ -319,9 +340,19 @@ impl StakingContract {
}

fn calculate_accrued(env: &Env, info: &StakeInfo) -> i128 {
if info.amount == 0 { return 0; }
Self::calculate_accrued_checked(env, info).unwrap_or(0)
}

fn calculate_accrued_checked(env: &Env, info: &StakeInfo) -> Result<i128, Error> {
if info.amount == 0 { return Ok(0); }
let elapsed = (env.ledger().sequence() as i128).saturating_sub(info.since_ledger as i128);
(info.amount * REWARD_RATE_BPS * elapsed) / (BPS_DENOM * LEDGERS_PER_YEAR)
let numerator = info
.amount
.checked_mul(REWARD_RATE_BPS)
.ok_or(Error::Overflow)?
.checked_mul(elapsed)
.ok_or(Error::Overflow)?;
Ok(numerator / (BPS_DENOM * LEDGERS_PER_YEAR))
}

fn isqrt(n: u64) -> u64 {
Expand Down
96 changes: 96 additions & 0 deletions contracts/contracts/staking/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,4 +504,100 @@ fn test_rewards_preserved_across_stake_additions() {

let info = c.get_stake(&t.artist1).unwrap();
assert!(info.pending_rewards > 0, "Pending rewards lost on re-stake");
}

#[test]
fn test_withdraw_at_exact_cooldown_boundary_succeeds() {
let t = setup();
let c = client(&t.env, &t.contract);

c.stake(&t.artist1, &100_0000000_i128);
c.unstake(&t.artist1, &40_0000000_i128);

advance_ledgers(&t.env, COOLDOWN_LEDGERS);
let withdrawn = c.withdraw(&t.artist1);
assert_eq!(withdrawn, 40_0000000_i128);
}

#[test]
fn test_partial_unstake_repeated_operations_preserve_accounting() {
let t = setup();
let c = client(&t.env, &t.contract);

c.stake(&t.artist1, &100_0000000_i128);
c.unstake(&t.artist1, &30_0000000_i128);
advance_ledgers(&t.env, 10);
c.unstake(&t.artist1, &20_0000000_i128);

let request = c.get_unstake_request(&t.artist1).unwrap();
assert_eq!(request.amount, 50_0000000_i128);
assert_eq!(c.total_staked(), 50_0000000_i128);

advance_ledgers(&t.env, COOLDOWN_LEDGERS - 10);
assert_eq!(c.try_withdraw(&t.artist1), Err(Ok(Error::CooldownNotMet)));

advance_ledgers(&t.env, 11);
let withdrawn = c.withdraw(&t.artist1);
assert_eq!(withdrawn, 50_0000000_i128);
assert_eq!(c.total_staked(), 50_0000000_i128);
}

#[test]
fn test_repeated_claims_have_stable_reward_math() {
let t = setup();
let c = client(&t.env, &t.contract);

c.stake(&t.artist1, &1_000_0000000_i128);

let quarter_year = (LEDGERS_PER_YEAR as u32) / 4;
advance_ledgers(&t.env, quarter_year);
let first_claim = c.claim_rewards(&t.artist1);
assert!(first_claim > 0);

advance_ledgers(&t.env, quarter_year);
let second_claim = c.claim_rewards(&t.artist1);
assert!(second_claim > 0);

let drift = if first_claim > second_claim {
first_claim - second_claim
} else {
second_claim - first_claim
};
assert!(drift < 50_000, "reward drift is too large: {}", drift);
}

#[test]
fn test_slash_restore_invariants_do_not_corrupt_totals() {
let t = setup();
let c = client(&t.env, &t.contract);

c.stake(&t.artist1, &500_0000000_i128);
c.stake(&t.artist2, &300_0000000_i128);
advance_ledgers(&t.env, 25_000);

let total_before_slash = c.total_staked();
let slash_amount = c.slash(&t.artist1);
let total_after_slash = c.total_staked();

assert_eq!(total_after_slash, total_before_slash - slash_amount);

let slashed_info = c.get_stake(&t.artist1).unwrap();
assert_eq!(slashed_info.pending_rewards, 0);
assert!(c.is_slashed(&t.artist1));

c.restore(&t.artist1);
assert!(!c.is_slashed(&t.artist1));

c.stake(&t.artist1, &MIN_STAKE);
assert_eq!(c.total_staked(), total_after_slash + MIN_STAKE);
}

#[test]
fn test_public_helpers_expose_admin_and_constants() {
let t = setup();
let c = client(&t.env, &t.contract);

assert_eq!(c.reward_rate_bps(), REWARD_RATE_BPS);
assert_eq!(c.cooldown_ledgers(), COOLDOWN_LEDGERS);
assert_eq!(c.get_admin().unwrap(), t.admin);
}
46 changes: 15 additions & 31 deletions frontend/src/components/NotificationCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import { Bell, Check } from 'lucide-react';
import { useNotifications, Notification } from '../hooks/useNotifications';
import { Toast, ToastProps } from './Toast';
import { useNotifications } from '../hooks/useNotifications';
import { dismissTipToast, useTipToastQueue } from '../contexts/tipToastQueue';
import { Toast } from './Toast';

export const NotificationCenter: React.FC = () => {
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotifications();
const { active: activeTipToast } = useTipToastQueue();
const [isOpen, setIsOpen] = useState(false);
const [toasts, setToasts] = useState<ToastProps[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const prevNotificationsRef = useRef<Notification[]>([]);

// Close dropdown when clicking outside
useEffect(() => {
Expand All @@ -22,30 +22,6 @@ export const NotificationCenter: React.FC = () => {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// Detect new notifications and show toast
useEffect(() => {
if (prevNotificationsRef.current.length > 0 && notifications.length > prevNotificationsRef.current.length) {
const newNotification = notifications[0];
// Only show toast if it's new (compare timestamps or IDs if needed, but length check is simple proxy)
addToast({
id: newNotification.id,
type: 'success', // Default to success for tips
title: newNotification.title,
message: newNotification.message,
onClose: removeToast,
});
}
prevNotificationsRef.current = notifications;
}, [notifications]);

const addToast = (toast: Omit<ToastProps, 'duration'>) => {
setToasts((prev) => [...prev, { ...toast, duration: 5000 }]);
};

const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};

const handleMarkAsRead = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
markAsRead(id);
Expand All @@ -55,9 +31,17 @@ export const NotificationCenter: React.FC = () => {
<div className="relative" ref={dropdownRef}>
{/* Toast Container */}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} />
))}
{activeTipToast && (
<Toast
id={activeTipToast.id}
type="tip"
title={activeTipToast.title}
message={activeTipToast.message}
duration={activeTipToast.duration}
priority={activeTipToast.priority}
onClose={dismissTipToast}
/>
Comment on lines +34 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the NotificationCenter.tsx file and examine lines 34-43
fd -t f NotificationCenter.tsx

Repository: OlufunbiIK/tip-tune

Length of output: 109


🏁 Script executed:

# Also find Toast.tsx
fd -t f Toast.tsx

Repository: OlufunbiIK/tip-tune

Length of output: 96


🏁 Script executed:

# Read NotificationCenter.tsx to examine the current code around lines 34-43
cat -n frontend/src/components/NotificationCenter.tsx

Repository: OlufunbiIK/tip-tune

Length of output: 5511


🏁 Script executed:

# Read Toast.tsx to understand its state management
cat -n frontend/src/components/Toast.tsx

Repository: OlufunbiIK/tip-tune

Length of output: 5185


🏁 Script executed:

# Find and examine the tipToastQueue context to understand the queue flow
fd -t f tipToastQueue

Repository: OlufunbiIK/tip-tune

Length of output: 145


🏁 Script executed:

# Examine the tipToastQueue context implementation
cat -n frontend/src/contexts/tipToastQueue.ts

Repository: OlufunbiIK/tip-tune

Length of output: 4996


Add a key so each queued toast gets a fresh lifecycle.

Toast.tsx maintains timer and exit state internally. Without a key here, React reuses that component instance for the next active toast, so the next toast inherits the stale exiting state from its predecessor and renders with the exit animation immediately.

Suggested fix
         {activeTipToast && (
           <Toast
+            key={activeTipToast.id}
             id={activeTipToast.id}
             type="tip"
             title={activeTipToast.title}
             message={activeTipToast.message}
             duration={activeTipToast.duration}
             priority={activeTipToast.priority}
             onClose={dismissTipToast}
           />
         )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{activeTipToast && (
<Toast
id={activeTipToast.id}
type="tip"
title={activeTipToast.title}
message={activeTipToast.message}
duration={activeTipToast.duration}
priority={activeTipToast.priority}
onClose={dismissTipToast}
/>
{activeTipToast && (
<Toast
key={activeTipToast.id}
id={activeTipToast.id}
type="tip"
title={activeTipToast.title}
message={activeTipToast.message}
duration={activeTipToast.duration}
priority={activeTipToast.priority}
onClose={dismissTipToast}
/>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/NotificationCenter.tsx` around lines 34 - 43, The
Toast instance in NotificationCenter is being reused across queued toasts
causing stale internal state; update the JSX where activeTipToast is rendered to
supply a unique key prop (e.g., use activeTipToast.id) on the Toast component so
React mounts a fresh instance per toast (look for activeTipToast and the Toast
component render and the dismissTipToast handler to confirm location).

)}
</div>

{/* Bell Icon */}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import { X, CheckCircle, AlertCircle, Info, Coins } from 'lucide-react';
import { useReducedMotion } from '../utils/animationUtils';
import type { ToastPriority } from '../contexts/tipToastQueue';

export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'tip';

Expand All @@ -9,6 +10,7 @@ export interface ToastProps {
type: ToastType;
title: string;
message: string;
priority?: ToastPriority;
duration?: number;
onClose: (id: string) => void;
}
Expand All @@ -34,6 +36,7 @@ export const Toast: React.FC<ToastProps> = ({
type,
title,
message,
priority = 'normal',
duration = 5000,
onClose,
}) => {
Expand Down Expand Up @@ -87,6 +90,7 @@ export const Toast: React.FC<ToastProps> = ({
className={[
'relative flex flex-col w-full max-w-sm overflow-hidden',
'rounded-xl bg-[#1a2942] border border-white/8 border-l-4 shadow-2xl shadow-black/40',
priority === 'high' ? 'ring-1 ring-amber-300/70' : '',
ACCENT_COLORS[type],
!reducedMotion && !exiting ? 'animate-slide-bounce' : '',
!reducedMotion && exiting ? 'opacity-0 translate-x-full transition-all duration-300' : '',
Expand Down
Loading