From 9a27dc2797fecef3441ecb41f6ca69d507a90fa9 Mon Sep 17 00:00:00 2001 From: CNduka001 Date: Sat, 26 Sep 2026 22:29:36 +0100 Subject: [PATCH] feat(staking): build staking dashboard with lock period selection and reward tracking (#917) --- .../__tests__/useStakingDashboard.test.tsx | 91 +++ src/hooks/useStakingDashboard.ts | 119 +++ src/hooks/useStakingPositions.ts | 18 + src/lib/queryKeys.ts | 7 + src/pages/StakingDashboardPage.tsx | 715 ++++++++++++++++++ .../StakingDashboardPage.integration.test.tsx | 214 ++++++ src/routes.tsx | 5 + .../stakingPositions.service.test.ts | 109 +++ src/services/stakingPositions.service.ts | 81 ++ .../__tests__/stakingPositions.utils.test.ts | 147 ++++ src/utils/stakingPositions.utils.ts | 86 +++ 11 files changed, 1592 insertions(+) create mode 100644 src/hooks/__tests__/useStakingDashboard.test.tsx create mode 100644 src/hooks/useStakingDashboard.ts create mode 100644 src/hooks/useStakingPositions.ts create mode 100644 src/pages/StakingDashboardPage.tsx create mode 100644 src/pages/__tests__/StakingDashboardPage.integration.test.tsx create mode 100644 src/services/__tests__/stakingPositions.service.test.ts create mode 100644 src/services/stakingPositions.service.ts create mode 100644 src/utils/__tests__/stakingPositions.utils.test.ts create mode 100644 src/utils/stakingPositions.utils.ts diff --git a/src/hooks/__tests__/useStakingDashboard.test.tsx b/src/hooks/__tests__/useStakingDashboard.test.tsx new file mode 100644 index 00000000..7bbc8acc --- /dev/null +++ b/src/hooks/__tests__/useStakingDashboard.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { + useStakeKeysMutation, + useUnstakeKeysMutation, + useClaimStakingRewardsMutation, +} from '../useStakingDashboard'; +import showToast from '@/utils/toast.util'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('useStakingDashboard hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('stakes creator keys and triggers success toast', async () => { + const { result } = renderHook( + () => useStakeKeysMutation('GAAA1234567890'), + { wrapper: createWrapper() } + ); + + await act(async () => { + await result.current.mutateAsync({ + keyId: 'creator-alpha', + quantity: 5, + lockPeriodDays: 90, + }); + }); + + expect(showToast.success).toHaveBeenCalledWith( + 'Keys staked successfully' + ); + }); + + it('unstakes creator keys and triggers success toast', async () => { + const { result } = renderHook( + () => useUnstakeKeysMutation('GAAA1234567890'), + { wrapper: createWrapper() } + ); + + await act(async () => { + await result.current.mutateAsync({ + positionId: 'pos-101', + keyId: 'creator-alpha', + }); + }); + + expect(showToast.success).toHaveBeenCalledWith( + 'Keys unstaked successfully' + ); + }); + + it('claims rewards and triggers success toast', async () => { + const { result } = renderHook( + () => useClaimStakingRewardsMutation('GAAA1234567890'), + { wrapper: createWrapper() } + ); + + await act(async () => { + await result.current.mutateAsync({ + positionId: 'pos-102', + keyId: 'creator-beta', + }); + }); + + expect(showToast.success).toHaveBeenCalledWith( + 'Staking rewards claimed' + ); + }); +}); diff --git a/src/hooks/useStakingDashboard.ts b/src/hooks/useStakingDashboard.ts new file mode 100644 index 00000000..4bfcb9c9 --- /dev/null +++ b/src/hooks/useStakingDashboard.ts @@ -0,0 +1,119 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +/** + * Staking dashboard contract mutations (#917). + * + * Stake, unstake, and claim-reward actions for the staking dashboard page. + * The on-chain wiring is not in the client yet, so each mutation simulates + * signing latency and resolves. On success the staking-positions and wallet + * holdings queries are invalidated so the dashboard reflects new state. + */ + +const SIGN_LATENCY_MS = 1200; + +async function submitStakingCall(fn: string, args: unknown) { + void fn; + void args; + await new Promise(resolve => + window.setTimeout(resolve, SIGN_LATENCY_MS) + ); + return { success: true as const }; +} + +// ── Stake keys ────────────────────────────────────────────────────────── + +export interface StakeKeysVariables { + /** Creator key to stake. */ + keyId: string; + /** Number of keys to stake. */ + quantity: number; + /** Lock period in days. */ + lockPeriodDays: number; +} + +export function useStakeKeysMutation(address: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'stake_keys', address], + mutationFn: (variables: StakeKeysVariables) => + submitStakingCall('stake_keys', { address, ...variables }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.stakingPositions(address), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + showToast.success('Keys staked successfully'); + }, + }); +} + +// ── Unstake keys ──────────────────────────────────────────────────────── + +export interface UnstakeKeysVariables { + /** Staking position id being unstaked. */ + positionId: string; + /** Creator key id of the staked position. */ + keyId: string; +} + +export function useUnstakeKeysMutation(address: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'unstake_keys', address], + mutationFn: (variables: UnstakeKeysVariables) => + submitStakingCall('unstake_keys', { address, ...variables }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.stakingPositions(address), + }); + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(address), + }); + showToast.success('Keys unstaked successfully'); + }, + }); +} + +// ── Claim staking rewards ─────────────────────────────────────────────── + +export interface ClaimStakingRewardsVariables { + /** Staking position id to claim rewards from. */ + positionId: string; + /** Creator key id. */ + keyId: string; +} + +export function useClaimStakingRewardsMutation(address: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'claim_staking_rewards', address], + mutationFn: (variables: ClaimStakingRewardsVariables) => + submitStakingCall('claim_staking_rewards', { + address, + ...variables, + }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.stakingPositions(address), + }); + showToast.success('Staking rewards claimed'); + }, + }); +} diff --git a/src/hooks/useStakingPositions.ts b/src/hooks/useStakingPositions.ts new file mode 100644 index 00000000..fd1e9d99 --- /dev/null +++ b/src/hooks/useStakingPositions.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { fetchStakingPositions } from '@/services/stakingPositions.service'; + +/** + * Active staking positions for a wallet (#921). + * + * Pulls from GET /users/:wallet/staking-positions so the profile page can + * render lock status, claimable rewards, and current value for every key + * the wallet currently has locked in the staking contract. + */ +export function useStakingPositions(address: string) { + return useQuery({ + queryKey: queryKeys.wallet.stakingPositions(address), + queryFn: () => fetchStakingPositions(address), + enabled: !!address, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 684e54ea..03df8442 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -55,6 +55,8 @@ export const queryKeys = { activity: (address: string) => ['wallet', address, 'activity'] as const, tradeHistory: (address: string) => ['wallet', address, 'tradeHistory'] as const, + stakingPositions: (address: string) => + ['wallet', address, 'staking-positions'] as const, }, notifications: { all: () => ['notifications'] as const, @@ -95,4 +97,9 @@ export const queryKeys = { summary: (wallet: string) => ['referrals', wallet, 'summary'] as const, wallets: (wallet: string) => ['referrals', wallet, 'wallets'] as const, }, + staking: { + all: () => ['staking'] as const, + dashboard: (wallet: string) => + ['staking', wallet, 'dashboard'] as const, + }, } as const; diff --git a/src/pages/StakingDashboardPage.tsx b/src/pages/StakingDashboardPage.tsx new file mode 100644 index 00000000..3dd53e9b --- /dev/null +++ b/src/pages/StakingDashboardPage.tsx @@ -0,0 +1,715 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Link } from 'react-router'; +import { useAccount } from 'wagmi'; +import { + ArrowLeft, + Lock, + TrendingUp, + Clock, + Coins, + Shield, + ChevronDown, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import ConnectWalletButton from '@/components/common/ConnectWalletButton'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useStakingPositions } from '@/hooks/useStakingPositions'; +import { useWalletHoldings } from '@/hooks/useWallet'; +import { + useStakeKeysMutation, + useUnstakeKeysMutation, + useClaimStakingRewardsMutation, +} from '@/hooks/useStakingDashboard'; +import { + isStakePositionUnlocked, + computeRemainingStakeLockSeconds, + formatClaimableReward, + formatStakedQuantity, + computeStakingPortfolioValueStroops, +} from '@/utils/stakingPositions.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { formatCountdownTime } from '@/utils/lockupCountdown.utils'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; +import type { StakingPosition } from '@/services/stakingPositions.service'; +import { cn } from '@/lib/utils'; + +// ── Lock period configuration ─────────────────────────────────────────── + +interface LockPeriodOption { + label: string; + days: number; + apyBps: number; +} + +const LOCK_PERIOD_OPTIONS: LockPeriodOption[] = [ + { label: '30 days', days: 30, apyBps: 500 }, + { label: '90 days', days: 90, apyBps: 1200 }, + { label: '180 days', days: 180, apyBps: 2000 }, + { label: '365 days', days: 365, apyBps: 3500 }, +]; + +function formatApy(bps: number): string { + return `${(bps / 100).toFixed(1)}%`; +} + +// ── Card styling constant ─────────────────────────────────────────────── + +const CARD = + 'rounded-2xl border border-white/10 bg-white/[0.02] shadow-2xl backdrop-blur-md'; + +// ── Summary stat card ─────────────────────────────────────────────────── + +function SummaryStat({ + label, + value, + icon: Icon, + accent = 'amber', +}: { + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; + accent?: 'amber' | 'emerald' | 'cyan'; +}) { + const accentClass = { + amber: 'from-amber-500/20 to-amber-600/5 text-amber-300', + emerald: 'from-emerald-500/20 to-emerald-600/5 text-emerald-300', + cyan: 'from-cyan-500/20 to-cyan-600/5 text-cyan-300', + }[accent]; + + return ( +
+
+
+
+

+ {label} +

+

+ {value} +

+
+
+ ); +} + +// ── Stake input form ──────────────────────────────────────────────────── + +function StakeForm({ + address, + holdings, +}: { + address: string; + holdings: Array<{ + creatorId: string; + quantity?: number | null; + name?: string; + keyName?: string; + }>; +}) { + const [selectedKey, setSelectedKey] = useState(''); + const [quantity, setQuantity] = useState(''); + const [lockPeriodIdx, setLockPeriodIdx] = useState(0); + const stakeMutation = useStakeKeysMutation(address); + + const availableKeys = useMemo( + () => holdings.filter(h => (h.quantity ?? 0) > 0), + [holdings] + ); + + const selectedHolding = availableKeys.find( + h => h.creatorId === selectedKey + ); + const maxQuantity = selectedHolding?.quantity ?? 0; + const parsedQuantity = Number(quantity); + const isValid = + selectedKey && + Number.isFinite(parsedQuantity) && + parsedQuantity > 0 && + parsedQuantity <= maxQuantity; + + const lockOption = LOCK_PERIOD_OPTIONS[lockPeriodIdx]; + + const handleStake = () => { + if (!isValid || !lockOption) return; + stakeMutation.mutate({ + keyId: selectedKey, + quantity: parsedQuantity, + lockPeriodDays: lockOption.days, + }); + }; + + return ( +
+

+ Stake creator keys +

+

+ Lock your keys for a period to earn staking rewards from the + reward pool. +

+ + {/* Key selector */} +
+ +
+ +
+
+ + {/* Quantity input */} +
+ +
+ setQuantity(e.target.value)} + placeholder={ + maxQuantity > 0 + ? `Max ${maxQuantity}` + : 'Select a key first' + } + className="w-full rounded-xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white placeholder:text-white/25 transition-colors focus:border-amber-500/40 focus:outline-none" + disabled={!selectedKey} + /> + {maxQuantity > 0 && ( + + )} +
+
+ + {/* Lock period selection */} +
+

+ Lock period +

+
+ {LOCK_PERIOD_OPTIONS.map((option, idx) => ( + + ))} +
+
+ + {/* Estimated rewards preview */} + {isValid && lockOption && ( +
+

+ Estimated rewards +

+

+ {( + (parsedQuantity * + lockOption.apyBps * + lockOption.days) / + (365 * 10000) + ).toFixed(4)}{' '} + XLM +

+

+ Based on current pool rate. Actual rewards vary. +

+
+ )} + + {/* Submit */} + +
+ ); +} + +// ── Active positions table row ────────────────────────────────────────── + +function PositionRow({ + position, + address, +}: { + position: StakingPosition; + address: string; +}) { + const [nowMs, setNowMs] = useState(() => Date.now()); + const unstakeMutation = useUnstakeKeysMutation(address); + const claimMutation = useClaimStakingRewardsMutation(address); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + + const unlocked = isStakePositionUnlocked(position, nowMs); + const remainingSec = computeRemainingStakeLockSeconds(position, nowMs); + const claimable = position.claimableReward ?? 0; + + const handleClaim = () => { + claimMutation.mutate({ + positionId: position.id, + keyId: position.keyId, + }); + }; + + const handleUnstake = () => { + unstakeMutation.mutate({ + positionId: position.id, + keyId: position.keyId, + }); + }; + + const expiryDate = new Date(position.unlockLedger * 1000); + + return ( +
+ {/* Key info */} +
+ + {position.keyName} + + + Staked {formatStakedQuantity(position.stakedQuantity)} + +
+ + {/* Expiry */} +
+ + Lock expiry + + + {expiryDate.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+ + {/* Lock status */} +
+ {unlocked ? ( + + + ) : ( + + + )} +
+ + {/* Claimable rewards */} +
+ + Claimable + + + {formatClaimableReward(claimable)} + +
+ + {/* Actions */} +
+ {claimable > 0 && ( + + )} + +
+
+ ); +} + +// ── Skeleton rows ─────────────────────────────────────────────────────── + +function PositionRowSkeleton() { + return ( +