From 2de6133cff22e7b067f960b0ab4bc84b1d3bdc3a Mon Sep 17 00:00:00 2001 From: ExcelDsigN-tech Date: Mon, 30 Mar 2026 15:06:42 +0100 Subject: [PATCH 1/2] feat(frontend): add prioritized real-time tip toast queue with tip-id dedupe --- .../src/components/NotificationCenter.tsx | 46 ++--- frontend/src/components/Toast.tsx | 4 + frontend/src/contexts/tipToastQueue.test.ts | 101 +++++++++++ frontend/src/contexts/tipToastQueue.ts | 160 ++++++++++++++++++ frontend/src/hooks/useNotifications.ts | 38 ++++- 5 files changed, 317 insertions(+), 32 deletions(-) create mode 100644 frontend/src/contexts/tipToastQueue.test.ts create mode 100644 frontend/src/contexts/tipToastQueue.ts diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index 08bdf9c..2d2ddcd 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -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([]); const dropdownRef = useRef(null); - const prevNotificationsRef = useRef([]); // Close dropdown when clicking outside useEffect(() => { @@ -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) => { - 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); @@ -55,9 +31,17 @@ export const NotificationCenter: React.FC = () => {
{/* Toast Container */}
- {toasts.map((toast) => ( - - ))} + {activeTipToast && ( + + )}
{/* Bell Icon */} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx index a38b563..2a253ff 100644 --- a/frontend/src/components/Toast.tsx +++ b/frontend/src/components/Toast.tsx @@ -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'; @@ -9,6 +10,7 @@ export interface ToastProps { type: ToastType; title: string; message: string; + priority?: ToastPriority; duration?: number; onClose: (id: string) => void; } @@ -34,6 +36,7 @@ export const Toast: React.FC = ({ type, title, message, + priority = 'normal', duration = 5000, onClose, }) => { @@ -87,6 +90,7 @@ export const Toast: React.FC = ({ 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' : '', diff --git a/frontend/src/contexts/tipToastQueue.test.ts b/frontend/src/contexts/tipToastQueue.test.ts new file mode 100644 index 0000000..c55b948 --- /dev/null +++ b/frontend/src/contexts/tipToastQueue.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + dismissTipToast, + enqueueTipToast, + getTipToastQueueSnapshot, + resetTipToastQueueForTests, +} from './tipToastQueue'; + +describe('tipToastQueue', () => { + beforeEach(() => { + resetTipToastQueueForTests(); + }); + + it('prioritizes large tips before normal tips', () => { + enqueueTipToast({ + id: 'tip-low', + tipId: 'tip-low', + title: 'Small tip', + message: '2 XLM', + priority: 'normal', + }); + + enqueueTipToast({ + id: 'tip-high', + tipId: 'tip-high', + title: 'Large tip', + message: '100 XLM', + priority: 'high', + }); + + const first = getTipToastQueueSnapshot(); + expect(first.active?.id).toBe('tip-high'); + expect(first.queuedCount).toBe(1); + + dismissTipToast('tip-high'); + const second = getTipToastQueueSnapshot(); + expect(second.active?.id).toBe('tip-low'); + }); + + it('suppresses duplicate tip IDs', () => { + const first = enqueueTipToast({ + id: 'dup-1', + tipId: 'tip-duplicate', + title: 'Tip A', + message: '10 XLM', + priority: 'normal', + }); + + const second = enqueueTipToast({ + id: 'dup-2', + tipId: 'tip-duplicate', + title: 'Tip B', + message: '50 XLM', + priority: 'high', + }); + + expect(first).toBe(true); + expect(second).toBe(false); + + const snapshot = getTipToastQueueSnapshot(); + expect(snapshot.active?.id).toBe('dup-1'); + expect(snapshot.queuedCount).toBe(0); + }); + + it('drains the queue gracefully in order', () => { + enqueueTipToast({ + id: 'tip-1', + tipId: 'tip-1', + title: 'Tip 1', + message: '1 XLM', + priority: 'normal', + }); + enqueueTipToast({ + id: 'tip-2', + tipId: 'tip-2', + title: 'Tip 2', + message: '2 XLM', + priority: 'normal', + }); + enqueueTipToast({ + id: 'tip-3', + tipId: 'tip-3', + title: 'Tip 3', + message: '3 XLM', + priority: 'normal', + }); + + expect(getTipToastQueueSnapshot().active?.id).toBe('tip-1'); + + dismissTipToast('tip-1'); + expect(getTipToastQueueSnapshot().active?.id).toBe('tip-2'); + + dismissTipToast('tip-2'); + expect(getTipToastQueueSnapshot().active?.id).toBe('tip-3'); + + dismissTipToast('tip-3'); + const finalState = getTipToastQueueSnapshot(); + expect(finalState.active).toBeNull(); + expect(finalState.queuedCount).toBe(0); + }); +}); diff --git a/frontend/src/contexts/tipToastQueue.ts b/frontend/src/contexts/tipToastQueue.ts new file mode 100644 index 0000000..eb4d1f6 --- /dev/null +++ b/frontend/src/contexts/tipToastQueue.ts @@ -0,0 +1,160 @@ +import { useSyncExternalStore } from 'react'; + +export type ToastPriority = 'high' | 'normal'; + +export interface TipToastQueueItem { + id: string; + tipId: string; + title: string; + message: string; + priority: ToastPriority; + createdAt: string; + duration?: number; +} + +interface TipToastQueueState { + active: TipToastQueueItem | null; + queuedCount: number; +} + +interface EnqueueTipToastInput { + id: string; + tipId: string; + title: string; + message: string; + priority: ToastPriority; + createdAt?: string; + duration?: number; +} + +interface StoredQueueItem extends TipToastQueueItem { + sequence: number; +} + +const MAX_SEEN_IDS = 500; + +class TipToastQueueStore { + private queue: StoredQueueItem[] = []; + private active: StoredQueueItem | null = null; + private seenTipIds: string[] = []; + private seenTipIdSet = new Set(); + private sequence = 0; + private listeners = new Set<() => void>(); + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getSnapshot = (): TipToastQueueState => ({ + active: this.active, + queuedCount: this.queue.length, + }); + + enqueue = (input: EnqueueTipToastInput): boolean => { + if (!input.tipId || this.seenTipIdSet.has(input.tipId)) { + return false; + } + + this.trackSeenTipId(input.tipId); + + const nextItem: StoredQueueItem = { + ...input, + createdAt: input.createdAt || new Date().toISOString(), + sequence: this.sequence++, + }; + + if (this.active && nextItem.priority === 'high' && this.active.priority !== 'high') { + this.insertByPriority(this.active); + this.active = nextItem; + this.emit(); + return true; + } + + this.insertByPriority(nextItem); + this.promoteNext(); + this.emit(); + return true; + }; + + dismiss = (id: string): void => { + if (this.active?.id === id) { + this.active = null; + this.promoteNext(); + this.emit(); + return; + } + + const initialLength = this.queue.length; + this.queue = this.queue.filter((item) => item.id !== id); + if (this.queue.length !== initialLength) { + this.emit(); + } + }; + + reset = (): void => { + this.queue = []; + this.active = null; + this.seenTipIds = []; + this.seenTipIdSet.clear(); + this.sequence = 0; + this.emit(); + }; + + private emit(): void { + this.listeners.forEach((listener) => listener()); + } + + private promoteNext(): void { + if (this.active || this.queue.length === 0) { + return; + } + + const [next, ...rest] = this.queue; + this.active = next; + this.queue = rest; + } + + private insertByPriority(item: StoredQueueItem): void { + this.queue.push(item); + this.queue.sort((left, right) => { + if (left.priority !== right.priority) { + return left.priority === 'high' ? -1 : 1; + } + return left.sequence - right.sequence; + }); + } + + private trackSeenTipId(tipId: string): void { + this.seenTipIds.push(tipId); + this.seenTipIdSet.add(tipId); + + if (this.seenTipIds.length <= MAX_SEEN_IDS) { + return; + } + + const evicted = this.seenTipIds.shift(); + if (evicted) { + this.seenTipIdSet.delete(evicted); + } + } +} + +const tipToastQueueStore = new TipToastQueueStore(); + +export const enqueueTipToast = (input: EnqueueTipToastInput): boolean => tipToastQueueStore.enqueue(input); + +export const dismissTipToast = (id: string): void => { + tipToastQueueStore.dismiss(id); +}; + +export const useTipToastQueue = (): TipToastQueueState => + useSyncExternalStore(tipToastQueueStore.subscribe, tipToastQueueStore.getSnapshot); + +export const getTipToastQueueSnapshot = (): TipToastQueueState => tipToastQueueStore.getSnapshot(); + +export const resetTipToastQueueForTests = (): void => { + tipToastQueueStore.reset(); +}; diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 94b8efd..97b921d 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import io, { Socket } from 'socket.io-client'; import apiClient from '../utils/api'; import { useWallet } from './useWallet'; +import { enqueueTipToast, type ToastPriority } from '../contexts/tipToastQueue'; export interface Notification { id: string; @@ -18,12 +19,41 @@ export const useNotifications = () => { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const socketRef = useRef(null); + const seenNotificationIdsRef = useRef>(new Set()); + + const inferTipPriority = (notification: Notification): ToastPriority => { + const rawAmount = + typeof notification.data?.amount === 'number' + ? notification.data.amount + : typeof notification.data?.tipAmount === 'number' + ? notification.data.tipAmount + : 0; + + return rawAmount >= 25 ? 'high' : 'normal'; + }; + + const enqueueTipToastFromNotification = useCallback((notification: Notification) => { + enqueueTipToast({ + id: notification.id, + tipId: notification.id, + title: notification.title, + message: notification.message, + priority: inferTipPriority(notification), + createdAt: notification.createdAt, + duration: 5000, + }); + }, []); // Fetch initial notifications const fetchNotifications = useCallback(async () => { try { const response = await apiClient.get('/notifications'); setNotifications(response.data.data); + seenNotificationIdsRef.current = new Set( + (response.data.data as Notification[]) + .map((notification) => notification.id) + .filter((id): id is string => Boolean(id)), + ); const countResponse = await apiClient.get('/notifications/unread-count'); setUnreadCount(countResponse.data.count); @@ -51,8 +81,14 @@ export const useNotifications = () => { }); socketRef.current.on('tipReceived', (notification: Notification) => { + if (seenNotificationIdsRef.current.has(notification.id)) { + return; + } + + seenNotificationIdsRef.current.add(notification.id); setNotifications((prev) => [notification, ...prev]); setUnreadCount((prev) => prev + 1); + enqueueTipToastFromNotification(notification); // Optional: Play sound // const audio = new Audio('/notification.mp3'); @@ -64,7 +100,7 @@ export const useNotifications = () => { socketRef.current.disconnect(); } }; - }, []); + }, [enqueueTipToastFromNotification]); // Initial fetch useEffect(() => { From cbb668c081271c82b0fcf938842d9cf7977a3946 Mon Sep 17 00:00:00 2001 From: ExcelDsigN-tech Date: Mon, 30 Mar 2026 15:06:48 +0100 Subject: [PATCH 2/2] fix(staking): harden reward precision cooldown and slash invariants with tests --- contracts/contracts/staking/Cargo.toml | 2 + contracts/contracts/staking/src/lib.rs | 51 ++++++++++--- contracts/contracts/staking/src/test.rs | 96 +++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/contracts/contracts/staking/Cargo.toml b/contracts/contracts/staking/Cargo.toml index 8f447fb..8727295 100644 --- a/contracts/contracts/staking/Cargo.toml +++ b/contracts/contracts/staking/Cargo.toml @@ -21,3 +21,5 @@ debug-assertions = false panic = "abort" codegen-units = 1 lto = true + +[workspace] diff --git a/contracts/contracts/staking/src/lib.rs b/contracts/contracts/staking/src/lib.rs index 9992313..fb0faad 100644 --- a/contracts/contracts/staking/src/lib.rs +++ b/contracts/contracts/staking/src/lib.rs @@ -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); @@ -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; @@ -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(()) @@ -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 { @@ -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) @@ -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::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), } } @@ -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 { + 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); @@ -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 { + 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 { diff --git a/contracts/contracts/staking/src/test.rs b/contracts/contracts/staking/src/test.rs index c487b79..c6b0570 100644 --- a/contracts/contracts/staking/src/test.rs +++ b/contracts/contracts/staking/src/test.rs @@ -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); } \ No newline at end of file