` and animate the wrapper instead.
+
+**Incorrect (animating SVG directly - no hardware acceleration):**
+
+```tsx
+function LoadingSpinner() {
+ return (
+
+
+
+ )
+}
+```
+
+**Correct (animating wrapper div - hardware accelerated):**
+
+```tsx
+function LoadingSpinner() {
+ return (
+
+
+
+
+
+ )
+}
+```
+
+This applies to all CSS transforms and transitions (`transform`, `opacity`, `translate`, `scale`, `rotate`). The wrapper div allows browsers to use GPU acceleration for smoother animations.
diff --git a/.claude/skills/react-best-practices/references/rules/rendering-conditional-render.md b/.claude/skills/react-best-practices/references/rules/rendering-conditional-render.md
new file mode 100644
index 00000000000..adb59445e63
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rendering-conditional-render.md
@@ -0,0 +1,40 @@
+---
+title: Use Explicit Conditional Rendering
+impact: MEDIUM
+impactDescription: prevents rendering 0 or NaN
+tags: rendering, conditional, jsx, falsy-values
+---
+
+## Use Explicit Conditional Rendering
+
+Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render.
+
+**Incorrect (renders "0" when count is 0):**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count && {count} }
+
+ )
+}
+
+// When count = 0, renders:
0
+// When count = 5, renders:
5
+```
+
+**Correct (renders nothing when count is 0):**
+
+```tsx
+function Badge({ count }: { count: number }) {
+ return (
+
+ {count > 0 ? {count} : null}
+
+ )
+}
+
+// When count = 0, renders:
+// When count = 5, renders:
5
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rendering-content-visibility.md b/.claude/skills/react-best-practices/references/rules/rendering-content-visibility.md
new file mode 100644
index 00000000000..681aa39f196
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rendering-content-visibility.md
@@ -0,0 +1,38 @@
+---
+title: CSS content-visibility for Long Lists
+impact: MEDIUM
+impactDescription: 10× faster initial render
+tags: rendering, css, content-visibility, long-lists
+---
+
+## CSS content-visibility for Long Lists
+
+Apply `content-visibility: auto` to defer off-screen rendering.
+
+**CSS:**
+
+```css
+.message-item {
+ content-visibility: auto;
+ contain-intrinsic-size: 0 80px;
+}
+```
+
+**Example:**
+
+```tsx
+function MessageList({ messages }: { messages: Message[] }) {
+ return (
+
+ {messages.map(msg => (
+
+ ))}
+
+ )
+}
+```
+
+For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render).
diff --git a/.claude/skills/react-best-practices/references/rules/rendering-hoist-jsx.md b/.claude/skills/react-best-practices/references/rules/rendering-hoist-jsx.md
new file mode 100644
index 00000000000..84dc4e2da27
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rendering-hoist-jsx.md
@@ -0,0 +1,44 @@
+---
+title: Hoist Static JSX Elements
+impact: MEDIUM
+impactDescription: avoids re-creation
+tags: rendering, jsx, static, optimization
+---
+
+## Hoist Static JSX Elements
+
+Extract static JSX outside components to avoid re-creation.
+
+**Incorrect (recreates element every render):**
+
+```tsx
+function LoadingSkeleton() {
+ return
+}
+
+function Container() {
+ return (
+
+ {loading && }
+
+ )
+}
+```
+
+**Correct (reuses same element):**
+
+```tsx
+const loadingSkeleton = (
+
+)
+
+function Container() {
+ return (
+
+ {loading && loadingSkeleton}
+
+ )
+}
+```
+
+This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render.
diff --git a/.claude/skills/react-best-practices/references/rules/rendering-hydration-no-flicker.md b/.claude/skills/react-best-practices/references/rules/rendering-hydration-no-flicker.md
new file mode 100644
index 00000000000..5cf0e79b69a
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rendering-hydration-no-flicker.md
@@ -0,0 +1,82 @@
+---
+title: Prevent Hydration Mismatch Without Flickering
+impact: MEDIUM
+impactDescription: avoids visual flicker and hydration errors
+tags: rendering, ssr, hydration, localStorage, flicker
+---
+
+## Prevent Hydration Mismatch Without Flickering
+
+When rendering content that depends on client-side storage (localStorage, cookies), avoid both SSR breakage and post-hydration flickering by injecting a synchronous script that updates the DOM before React hydrates.
+
+**Incorrect (breaks SSR):**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ // localStorage is not available on server - throws error
+ const theme = localStorage.getItem('theme') || 'light'
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+Server-side rendering will fail because `localStorage` is undefined.
+
+**Incorrect (visual flickering):**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ const [theme, setTheme] = useState('light')
+
+ useEffect(() => {
+ // Runs after hydration - causes visible flash
+ const stored = localStorage.getItem('theme')
+ if (stored) {
+ setTheme(stored)
+ }
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+Component first renders with default value (`light`), then updates after hydration, causing a visible flash of incorrect content.
+
+**Correct (no flicker, no hydration mismatch):**
+
+```tsx
+function ThemeWrapper({ children }: { children: ReactNode }) {
+ return (
+ <>
+
+ {children}
+
+
+ >
+ )
+}
+```
+
+The inline script executes synchronously before showing the element, ensuring the DOM already has the correct value. No flickering, no hydration mismatch.
+
+This pattern is especially useful for theme toggles, user preferences, authentication states, and any client-only data that should render immediately without flashing default values.
diff --git a/.claude/skills/react-best-practices/references/rules/rendering-svg-precision.md b/.claude/skills/react-best-practices/references/rules/rendering-svg-precision.md
new file mode 100644
index 00000000000..be80c9d0441
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rendering-svg-precision.md
@@ -0,0 +1,28 @@
+---
+title: Optimize SVG Precision
+impact: MEDIUM
+impactDescription: reduces file size
+tags: rendering, svg, optimization, svgo
+---
+
+## Optimize SVG Precision
+
+Reduce SVG coordinate precision to decrease file size. The optimal precision depends on the viewBox size, but in general reducing precision should be considered.
+
+**Incorrect (excessive precision):**
+
+```svg
+
+```
+
+**Correct (1 decimal place):**
+
+```svg
+
+```
+
+**Automate with SVGO:**
+
+```bash
+npx svgo --precision=1 --multipass icon.svg
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-defer-reads.md b/.claude/skills/react-best-practices/references/rules/rerender-defer-reads.md
new file mode 100644
index 00000000000..e867c95f02f
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-defer-reads.md
@@ -0,0 +1,39 @@
+---
+title: Defer State Reads to Usage Point
+impact: MEDIUM
+impactDescription: avoids unnecessary subscriptions
+tags: rerender, searchParams, localStorage, optimization
+---
+
+## Defer State Reads to Usage Point
+
+Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks.
+
+**Incorrect (subscribes to all searchParams changes):**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const searchParams = useSearchParams()
+
+ const handleShare = () => {
+ const ref = searchParams.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
Share
+}
+```
+
+**Correct (reads on demand, no subscription):**
+
+```tsx
+function ShareButton({ chatId }: { chatId: string }) {
+ const handleShare = () => {
+ const params = new URLSearchParams(window.location.search)
+ const ref = params.get('ref')
+ shareChat(chatId, { ref })
+ }
+
+ return
Share
+}
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-dependencies.md b/.claude/skills/react-best-practices/references/rules/rerender-dependencies.md
new file mode 100644
index 00000000000..dde1b450ff3
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-dependencies.md
@@ -0,0 +1,45 @@
+---
+title: Narrow Effect Dependencies
+impact: MEDIUM
+impactDescription: minimizes effect re-runs
+tags: rerender, useEffect, dependencies, optimization
+---
+
+## Narrow Effect Dependencies
+
+Specify primitive dependencies instead of objects to minimize effect re-runs.
+
+**Incorrect (re-runs on any user field change):**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user])
+```
+
+**Correct (re-runs only when id changes):**
+
+```tsx
+useEffect(() => {
+ console.log(user.id)
+}, [user.id])
+```
+
+**For derived state, compute outside effect:**
+
+```tsx
+// Incorrect: runs on width=767, 766, 765...
+useEffect(() => {
+ if (width < 768) {
+ enableMobileMode()
+ }
+}, [width])
+
+// Correct: runs only on boolean transition
+const isMobile = width < 768
+useEffect(() => {
+ if (isMobile) {
+ enableMobileMode()
+ }
+}, [isMobile])
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-derived-state.md b/.claude/skills/react-best-practices/references/rules/rerender-derived-state.md
new file mode 100644
index 00000000000..a15177caad4
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-derived-state.md
@@ -0,0 +1,29 @@
+---
+title: Subscribe to Derived State
+impact: MEDIUM
+impactDescription: reduces re-render frequency
+tags: rerender, derived-state, media-query, optimization
+---
+
+## Subscribe to Derived State
+
+Subscribe to derived boolean state instead of continuous values to reduce re-render frequency.
+
+**Incorrect (re-renders on every pixel change):**
+
+```tsx
+function Sidebar() {
+ const width = useWindowWidth() // updates continuously
+ const isMobile = width < 768
+ return
+}
+```
+
+**Correct (re-renders only when boolean changes):**
+
+```tsx
+function Sidebar() {
+ const isMobile = useMediaQuery('(max-width: 767px)')
+ return
+}
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-lazy-state-init.md b/.claude/skills/react-best-practices/references/rules/rerender-lazy-state-init.md
new file mode 100644
index 00000000000..4ecb350fbad
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-lazy-state-init.md
@@ -0,0 +1,58 @@
+---
+title: Use Lazy State Initialization
+impact: MEDIUM
+impactDescription: wasted computation on every render
+tags: react, hooks, useState, performance, initialization
+---
+
+## Use Lazy State Initialization
+
+Pass a function to `useState` for expensive initial values. Without the function form, the initializer runs on every render even though the value is only used once.
+
+**Incorrect (runs on every render):**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs on EVERY render, even after initialization
+ const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ // When query changes, buildSearchIndex runs again unnecessarily
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs on every render
+ const [settings, setSettings] = useState(
+ JSON.parse(localStorage.getItem('settings') || '{}')
+ )
+
+ return
+}
+```
+
+**Correct (runs only once):**
+
+```tsx
+function FilteredList({ items }: { items: Item[] }) {
+ // buildSearchIndex() runs ONLY on initial render
+ const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items))
+ const [query, setQuery] = useState('')
+
+ return
+}
+
+function UserProfile() {
+ // JSON.parse runs only on initial render
+ const [settings, setSettings] = useState(() => {
+ const stored = localStorage.getItem('settings')
+ return stored ? JSON.parse(stored) : {}
+ })
+
+ return
+}
+```
+
+Use lazy initialization when computing initial values from localStorage/sessionStorage, building data structures (indexes, maps), reading from the DOM, or performing heavy transformations.
+
+For simple primitives (`useState(0)`), direct references (`useState(props.value)`), or cheap literals (`useState({})`), the function form is unnecessary.
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-memo.md b/.claude/skills/react-best-practices/references/rules/rerender-memo.md
new file mode 100644
index 00000000000..948d92defba
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-memo.md
@@ -0,0 +1,42 @@
+---
+title: Extract to Memoized Components
+impact: MEDIUM
+impactDescription: enables early returns
+tags: rerender, memo, useMemo, optimization
+---
+
+## Extract to Memoized Components
+
+Extract expensive work into memoized components to enable early returns before computation.
+
+**Incorrect (computes avatar even when loading):**
+
+```tsx
+function Profile({ user, loading }: Props) {
+ const avatar = useMemo(() => {
+ const id = computeAvatarId(user)
+ return
+ }, [user])
+
+ if (loading) return
+ return {avatar}
+}
+```
+
+**Correct (skips computation when loading):**
+
+```tsx
+const UserAvatar = memo(function UserAvatar({ user }: { user: User }) {
+ const id = useMemo(() => computeAvatarId(user), [user])
+ return
+})
+
+function Profile({ user, loading }: Props) {
+ if (loading) return
+ return (
+
+
+
+ )
+}
+```
diff --git a/.claude/skills/react-best-practices/references/rules/rerender-transitions.md b/.claude/skills/react-best-practices/references/rules/rerender-transitions.md
new file mode 100644
index 00000000000..d99f43f7642
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/rerender-transitions.md
@@ -0,0 +1,40 @@
+---
+title: Use Transitions for Non-Urgent Updates
+impact: MEDIUM
+impactDescription: maintains UI responsiveness
+tags: rerender, transitions, startTransition, performance
+---
+
+## Use Transitions for Non-Urgent Updates
+
+Mark frequent, non-urgent state updates as transitions to maintain UI responsiveness.
+
+**Incorrect (blocks UI on every scroll):**
+
+```tsx
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => setScrollY(window.scrollY)
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
+
+**Correct (non-blocking updates):**
+
+```tsx
+import { startTransition } from 'react'
+
+function ScrollTracker() {
+ const [scrollY, setScrollY] = useState(0)
+ useEffect(() => {
+ const handler = () => {
+ startTransition(() => setScrollY(window.scrollY))
+ }
+ window.addEventListener('scroll', handler, { passive: true })
+ return () => window.removeEventListener('scroll', handler)
+ }, [])
+}
+```
diff --git a/.claude/skills/react-best-practices/references/rules/server-cache-lru.md b/.claude/skills/react-best-practices/references/rules/server-cache-lru.md
new file mode 100644
index 00000000000..3cef30a5570
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/server-cache-lru.md
@@ -0,0 +1,37 @@
+---
+title: Cross-Request LRU Caching
+impact: HIGH
+impactDescription: caches across requests
+tags: server, cache, lru, cross-request
+---
+
+## Cross-Request LRU Caching
+
+`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache.
+
+**Implementation:**
+
+```typescript
+import { LRUCache } from 'lru-cache'
+
+const cache = new LRUCache({
+ max: 1000,
+ ttl: 5 * 60 * 1000 // 5 minutes
+})
+
+export async function getUser(id: string) {
+ const cached = cache.get(id)
+ if (cached) return cached
+
+ const user = await db.user.findUnique({ where: { id } })
+ cache.set(id, user)
+ return user
+}
+
+// Request 1: DB query, result cached
+// Request 2: cache hit, no DB query
+```
+
+Use when sequential user actions hit multiple endpoints needing the same data within seconds. In serverless, consider Redis for cross-process caching.
+
+Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache)
diff --git a/.claude/skills/react-best-practices/references/rules/server-cache-react.md b/.claude/skills/react-best-practices/references/rules/server-cache-react.md
new file mode 100644
index 00000000000..a662c6a6781
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/server-cache-react.md
@@ -0,0 +1,26 @@
+---
+title: Per-Request Deduplication with React.cache()
+impact: HIGH
+impactDescription: deduplicates within request
+tags: server, cache, react-cache, deduplication
+---
+
+## Per-Request Deduplication with React.cache()
+
+Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most.
+
+**Usage:**
+
+```typescript
+import { cache } from 'react'
+
+export const getCurrentUser = cache(async () => {
+ const session = await auth()
+ if (!session?.user?.id) return null
+ return await db.user.findUnique({
+ where: { id: session.user.id }
+ })
+})
+```
+
+Within a single request, multiple calls to `getCurrentUser()` execute the query only once.
diff --git a/.claude/skills/react-best-practices/references/rules/server-parallel-fetching.md b/.claude/skills/react-best-practices/references/rules/server-parallel-fetching.md
new file mode 100644
index 00000000000..12b03666b56
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/server-parallel-fetching.md
@@ -0,0 +1,79 @@
+---
+title: Parallel Data Fetching with Component Composition
+impact: HIGH
+impactDescription: eliminates server-side waterfalls
+tags: server, rsc, parallel-fetching, composition
+---
+
+## Parallel Data Fetching with Component Composition
+
+React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching.
+
+**Incorrect (Sidebar waits for Page's fetch to complete):**
+
+```tsx
+export default async function Page() {
+ const header = await fetchHeader()
+ return (
+
+ )
+}
+
+async function Sidebar() {
+ const items = await fetchSidebarItems()
+ return {items.map(renderItem)}
+}
+```
+
+**Correct (both fetch simultaneously):**
+
+```tsx
+async function Header() {
+ const data = await fetchHeader()
+ return {data}
+}
+
+async function Sidebar() {
+ const items = await fetchSidebarItems()
+ return {items.map(renderItem)}
+}
+
+export default function Page() {
+ return (
+
+
+
+
+ )
+}
+```
+
+**Alternative with children prop:**
+
+```tsx
+async function Layout({ children }: { children: ReactNode }) {
+ const header = await fetchHeader()
+ return (
+
+
{header}
+ {children}
+
+ )
+}
+
+async function Sidebar() {
+ const items = await fetchSidebarItems()
+ return {items.map(renderItem)}
+}
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
+```
diff --git a/.claude/skills/react-best-practices/references/rules/server-serialization.md b/.claude/skills/react-best-practices/references/rules/server-serialization.md
new file mode 100644
index 00000000000..fb6cf2a5eec
--- /dev/null
+++ b/.claude/skills/react-best-practices/references/rules/server-serialization.md
@@ -0,0 +1,38 @@
+---
+title: Minimize Serialization at RSC Boundaries
+impact: HIGH
+impactDescription: reduces data transfer size
+tags: server, rsc, serialization, props
+---
+
+## Minimize Serialization at RSC Boundaries
+
+The React Server/Client boundary serializes all object properties. Only pass fields that the client actually uses.
+
+**Incorrect (serializes all 50 fields):**
+
+```tsx
+async function Page() {
+ const user = await fetchUser() // 50 fields
+ return
+}
+
+'use client'
+function Profile({ user }: { user: User }) {
+ return {user.name}
// uses 1 field
+}
+```
+
+**Correct (serializes only 1 field):**
+
+```tsx
+async function Page() {
+ const user = await fetchUser()
+ return
+}
+
+'use client'
+function Profile({ name }: { name: string }) {
+ return {name}
+}
+```
diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json
index 7fb7c526a96..1763f62f254 100644
--- a/src/assets/translations/en/main.json
+++ b/src/assets/translations/en/main.json
@@ -2675,6 +2675,9 @@
"enter": "Enter",
"exit": "Exit",
"enterAsset": "Enter %{asset}",
+ "actions": {
+ "restake": "Restake"
+ },
"yield": "Yield",
"apy": "APY",
"apr": "APR",
@@ -2714,6 +2717,7 @@
"preferred": "Preferred",
"pending": "Pending",
"ready": "Ready",
+ "bestReturn": "Best Return",
"highestApy": "Highest APY",
"lowestApy": "Lowest APY",
"highestTvl": "Highest TVL",
@@ -2801,7 +2805,35 @@
"initiateFailedDescription": "Failed to initiate transaction sequence.",
"quoteFailedTitle": "Quote failed",
"quoteFailedDescription": "Unable to get a quote for this transaction. Please try again."
- }
+ },
+ "underMaintenance": "Under Maintenance",
+ "underMaintenanceDescription": "This yield opportunity is currently under maintenance. Deposits may be unavailable.",
+ "deprecated": "Deprecated",
+ "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon.",
+ "learnMore": "Learn more",
+ "noAvailableYields": "No yield opportunities available for your assets",
+ "connectWalletAvailable": "Connect a wallet to see yields available for your assets",
+ "aboutProvider": "About %{provider}",
+ "visitWebsite": "Visit Website",
+ "providerDescriptions": {
+ "morpho": "Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.",
+ "morpho-aave": "Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.",
+ "morpho-compound": "Morpho is a money market and vault infrastructure protocol, multiply audited by top-tier security firms, live since 2022, with $2.5M in bug bounty incentives.",
+ "lido": "Lido is a liquid staking protocol that lets users stake ETH while keeping liquidity via stETH. Multiply audited by top-tier security firms, live since 2020, with $2M in bug bounty incentives.",
+ "aave": "Aave is a multi-chain lending marketplace enabling users to lend, borrow, and build advanced strategies. Multiply audited by top-tier security firms, live since 2017, with $1M in bug bounty incentives.",
+ "compound": "Compound is a foundational DeFi money market with algorithmic interest rates. Multiply audited by top-tier security firms, live since 2018, with $1M in bug bounty incentives.",
+ "kamino": "Kamino is a Solana DeFi suite unifying lending, liquidity, and leverage into one platform. Runs an Immunefi program with up to $1.5M maximum bounty.",
+ "fluid": "Fluid is a liquidity layer built by the Instadapp team, connecting lending, DEX, borrowing, and stablecoin markets into one efficient system. Multiply audited by top-tier security firms, live since 2024, with $0.5M in bug bounty incentives.",
+ "venus": "Venus is a lending and borrowing protocol focused on BNB Chain. Emphasizes security through third-party audits and an ongoing bug bounty program.",
+ "gearbox": "Gearbox is a composable leverage protocol enabling credit accounts that plug into DeFi strategies. Multiply audited by top-tier security firms, live since 2021, with $0.2M in bug bounty incentives."
+ },
+ "otherYields": "Other %{symbol} Yields",
+ "availableToDeposit": "Available to Deposit",
+ "availableToDepositTooltip": "This is the amount of %{symbol} in your wallet that you can deposit into this yield opportunity.",
+ "potentialEarningsAmount": "%{amount}/yr at %{apy}% APY",
+ "depositNow": "Deposit Now",
+ "strategyInfo": "Strategy Info",
+ "overview": "Overview"
},
"earn": {
"enterFrom": "Enter from",
diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx
index c0f10f7a42a..65b1e048cd1 100644
--- a/src/components/Layout/Header/Header.tsx
+++ b/src/components/Layout/Header/Header.tsx
@@ -83,24 +83,16 @@ export const Header = memo(() => {
const height = useMemo(() => ref.current?.getBoundingClientRect()?.height ?? 0, [])
const { scrollY } = useScroll()
- // Responsive display based on viewport width
- const searchBoxDisplay = useMemo(
- () => ({
- base: 'none',
- '2xl': 'flex',
- // Hide at smaller breakpoints where it would get cramped
- xl: 'none',
- }),
- [],
- )
+ const searchBoxDisplay = {
+ base: 'none',
+ '2xl': 'flex',
+ xl: 'none',
+ }
- const iconButtonDisplay = useMemo(
- () => ({
- base: 'flex',
- '2xl': 'none',
- }),
- [],
- )
+ const iconButtonDisplay = {
+ base: 'flex',
+ '2xl': 'none',
+ }
useEffect(() => {
return scrollY.onChange(() => setY(scrollY.get()))
@@ -124,12 +116,12 @@ export const Header = memo(() => {
const hasWallet = Boolean(walletInfo?.deviceId)
const earnSubMenuItems = useMemo(
() => [
+ ...(isYieldXyzEnabled
+ ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp, isNew: true }]
+ : []),
{ label: 'navBar.tcy', path: '/tcy', icon: TCYIcon },
{ label: 'navBar.pools', path: '/pools', icon: TbPool },
{ label: 'navBar.lending', path: '/lending', icon: TbBuildingBank },
- ...(isYieldXyzEnabled
- ? [{ label: 'navBar.yields', path: '/yields', icon: TbTrendingUp }]
- : []),
],
[isYieldXyzEnabled],
)
@@ -188,7 +180,11 @@ export const Header = memo(() => {
items={exploreSubMenuItems}
defaultPath='/assets'
/>
-
+
}
>
- {translate(item.label)}
+
+ {translate(item.label)}
+ {item.isNew && (
+
+ {translate('common.new')}
+
+ )}
+
)
})}
diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx
index 65151b1b2ad..e9cfbc29688 100644
--- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx
+++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx
@@ -13,6 +13,7 @@ import { getTransactionButtonText } from '@/lib/yieldxyz/utils'
import { GradientApy } from '@/pages/Yields/components/GradientApy'
import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList'
import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow'
+import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers'
import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess'
import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow'
import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders'
@@ -310,6 +311,12 @@ export const EarnConfirm = memo(() => {
)}
+ {selectedYield && (
+
+
+
+ )}
+
{stepsToShow.length > 0 && (
diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx
index 3fcda2ca603..55699db4b77 100644
--- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx
+++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx
@@ -392,16 +392,27 @@ export const EarnInput = memo(
return yieldsData.byInputAssetId[sellAsset.assetId] ?? []
}, [sellAsset?.assetId, yieldsData?.byInputAssetId])
+ const defaultYieldForAsset = useMemo(() => {
+ if (yieldsForAsset.length === 0) return undefined
+
+ const sortedByApy = [...yieldsForAsset].sort(
+ (a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0),
+ )
+
+ const userBalance = bnOrZero(sellAssetBalanceCryptoPrecision)
+ const actionableYield = sortedByApy.find(y => {
+ const minDepositAmount = bnOrZero(y.mechanics?.entryLimits?.minimum)
+ return minDepositAmount.lte(0) || userBalance.gte(minDepositAmount)
+ })
+
+ return actionableYield ?? sortedByApy[0]
+ }, [yieldsForAsset, sellAssetBalanceCryptoPrecision])
+
useEffect(() => {
- if (yieldsForAsset.length > 0 && !selectedYieldId) {
- const sortedByApy = [...yieldsForAsset].sort(
- (a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0),
- )
- if (sortedByApy[0]) {
- dispatch(tradeEarnInput.actions.setSelectedYieldId(sortedByApy[0].id))
- }
+ if (defaultYieldForAsset && !selectedYieldId) {
+ dispatch(tradeEarnInput.actions.setSelectedYieldId(defaultYieldForAsset.id))
}
- }, [yieldsForAsset, selectedYieldId, dispatch])
+ }, [defaultYieldForAsset, selectedYieldId, dispatch])
const handleSubmit = useCallback(
(e: FormEvent) => {
diff --git a/src/components/StakingVaults/DeFiEarn.tsx b/src/components/StakingVaults/DeFiEarn.tsx
index 2b2b2b4806f..21235f932f0 100644
--- a/src/components/StakingVaults/DeFiEarn.tsx
+++ b/src/components/StakingVaults/DeFiEarn.tsx
@@ -66,28 +66,14 @@ export const DeFiEarn: React.FC = ({
const map = new Map()
if (isYieldXyzEnabled && yieldOpportunities) {
- console.debug('[DeFiEarn] Yield opportunities:', JSON.stringify(yieldOpportunities, null, 2))
yieldOpportunities.forEach(item => {
map.set(item.assetId, item)
})
}
- console.debug(
- '[DeFiEarn] Legacy positions:',
- JSON.stringify(
- legacyPositions.map(p => ({ assetId: p.assetId, fiatAmount: p.fiatAmount })),
- null,
- 2,
- ),
- )
-
legacyPositions.forEach(item => {
const existing = map.get(item.assetId)
if (existing) {
- console.debug('[DeFiEarn] Merging asset:', item.assetId, {
- yieldFiat: existing.fiatAmount,
- legacyFiat: item.fiatAmount,
- })
const mergedFiatAmount = bnOrZero(existing.fiatAmount)
.plus(bnOrZero(item.fiatAmount))
.toFixed(2)
@@ -106,24 +92,11 @@ export const DeFiEarn: React.FC = ({
}
})
- const result = Array.from(map.values()).sort((a, b) =>
- bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber(),
- )
- console.debug(
- '[DeFiEarn] Final merged data:',
- JSON.stringify(
- result.map(r => ({
- assetId: r.assetId,
- fiatAmount: r.fiatAmount,
- isYield: r.isYield,
- yieldCount: r.yieldOpportunities?.length,
- stakingCount: r.opportunities.staking.length,
- })),
- null,
- 2,
- ),
- )
- return result
+ return Array.from(map.values()).sort((a, b) => {
+ const balanceDiff = bnOrZero(b.fiatAmount).minus(bnOrZero(a.fiatAmount)).toNumber()
+ if (balanceDiff !== 0) return balanceDiff
+ return bnOrZero(b.apy).minus(bnOrZero(a.apy)).toNumber()
+ })
}, [isYieldXyzEnabled, legacyPositions, yieldOpportunities])
const chainIds = useMemo(() => {
diff --git a/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts b/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts
index 9a42518192f..60d6f0462ef 100644
--- a/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts
+++ b/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts
@@ -99,16 +99,14 @@ export const useYieldAsOpportunities = (
: yieldItem.rewardRate.total.toString()
: yieldItem.rewardRate.total.toString()
- if (bnOrZero(totalUsd).gt(0.01) || bnOrZero(totalCrypto).gt(0)) {
- aggregatedByAssetId[inputAssetId].yieldOpportunities.push({
- yieldId: yieldItem.id,
- providerName: yieldItem.metadata.name || yieldItem.providerId,
- providerIcon: yieldItem.metadata.logoURI,
- apy: yieldItem.rewardRate.total.toString(),
- fiatAmount: bnOrZero(totalUsd).toFixed(2),
- cryptoAmount: bnOrZero(totalCrypto).toString(),
- })
- }
+ aggregatedByAssetId[inputAssetId].yieldOpportunities.push({
+ yieldId: yieldItem.id,
+ providerName: yieldItem.metadata.name || yieldItem.providerId,
+ providerIcon: yieldItem.metadata.logoURI,
+ apy: yieldItem.rewardRate.total.toString(),
+ fiatAmount: bnOrZero(totalUsd).toFixed(2),
+ cryptoAmount: bnOrZero(totalCrypto).toString(),
+ })
const searchable = aggregatedByAssetId[inputAssetId].searchable
const tokenSymbol = yieldItem.inputTokens?.[0]?.symbol ?? yieldItem.token.symbol
@@ -128,22 +126,6 @@ export const useYieldAsOpportunities = (
})
})
- console.debug(
- '[useYieldAsOpportunities] Result:',
- JSON.stringify(
- result.map(r => ({
- assetId: r.assetId,
- fiatAmount: r.fiatAmount,
- yieldOpportunities: r.yieldOpportunities.map(y => ({
- providerName: y.providerName,
- fiatAmount: y.fiatAmount,
- })),
- })),
- null,
- 2,
- ),
- )
-
return result
}, [yieldBalancesData?.aggregated, yieldsData?.all])
diff --git a/src/lib/yieldxyz/getYieldDisplayName.test.ts b/src/lib/yieldxyz/getYieldDisplayName.test.ts
new file mode 100644
index 00000000000..289704e82a7
--- /dev/null
+++ b/src/lib/yieldxyz/getYieldDisplayName.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+
+import { getYieldDisplayName } from './getYieldDisplayName'
+import type { AugmentedYieldDto } from './types'
+
+const mockYield = (providerId: string, tokenSymbol: string, metadataName: string) =>
+ ({
+ providerId,
+ token: { symbol: tokenSymbol },
+ metadata: { name: metadataName },
+ }) as AugmentedYieldDto
+
+describe('getYieldDisplayName', () => {
+ describe('returns token symbol for standard yields', () => {
+ it.each([
+ ['aave', 'USDC', 'Aave v3 Lending'],
+ ['fluid', 'USDT', '(PoS) Tether USD Lending Fluid Vault'],
+ ['compound', 'WETH', 'Compound v3 Lending'],
+ ['lido', 'stETH', 'Lido Ethereum Staking'],
+ ['gearbox', 'USDC', 'USDC Trade USDC v3 Gearbox Vault'],
+ ])('%s %s → %s', (providerId, symbol, metadataName) => {
+ expect(getYieldDisplayName(mockYield(providerId, symbol, metadataName))).toBe(symbol)
+ })
+ })
+
+ describe('returns curator name for Morpho/Yearn vaults with known curators', () => {
+ it.each([
+ ['morpho', 'Steakhouse High Yield USDC Morpho Vault', 'Steakhouse High Yield'],
+ ['morpho', 'Steakhouse Prime USDC Morpho Vault', 'Steakhouse Prime'],
+ ['morpho', 'Gauntlet USDT Vault Morpho Vault', 'Gauntlet'],
+ ['morpho', 'Clearstar USDC Reactor Morpho Vault', 'Clearstar'],
+ ['morpho', 'Yearn OG USDT Morpho Vault', 'Yearn OG'],
+ ['yearn', 'Yearn OG vbETH Compounder Yearn Vault V3', 'Yearn OG'],
+ ])('%s "%s" → %s', (providerId, metadataName, expected) => {
+ expect(getYieldDisplayName(mockYield(providerId, 'TOKEN', metadataName))).toBe(expected)
+ })
+ })
+
+ describe('returns symbol when vault has no matching curator prefix', () => {
+ it.each([
+ ['yearn', 'vbUSDT', 'Morpho Yearn OG USDT Compounder Yearn Vault V3'],
+ ['yearn', 'AUSD', 'AUSD yVault Yearn Vault V3'],
+ ])('%s %s → %s (no curator prefix)', (providerId, symbol, metadataName) => {
+ expect(getYieldDisplayName(mockYield(providerId, symbol, metadataName))).toBe(symbol)
+ })
+ })
+})
diff --git a/src/lib/yieldxyz/getYieldDisplayName.ts b/src/lib/yieldxyz/getYieldDisplayName.ts
new file mode 100644
index 00000000000..78035d559d8
--- /dev/null
+++ b/src/lib/yieldxyz/getYieldDisplayName.ts
@@ -0,0 +1,31 @@
+import type { AugmentedYieldDto } from './types'
+
+const VAULT_CURATORS = [
+ 'Steakhouse High Yield',
+ 'Steakhouse Prime',
+ 'Steakhouse',
+ 'Gauntlet',
+ 'Clearstar',
+ 'Yearn OG',
+ 'Yearn',
+ 'Re7',
+ 'Usual',
+ 'Smokehouse',
+]
+
+export const getYieldDisplayName = (yieldItem: AugmentedYieldDto): string => {
+ const { token, providerId, metadata } = yieldItem
+ const metadataName = metadata?.name ?? ''
+
+ const isVaultWithCurator =
+ providerId === 'morpho' ||
+ metadataName.includes('Morpho Vault') ||
+ metadataName.includes('Yearn Vault')
+
+ if (isVaultWithCurator) {
+ const curator = VAULT_CURATORS.find(c => metadataName.startsWith(c))
+ if (curator) return curator
+ }
+
+ return token.symbol
+}
diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts
index be584174067..d4197dc0627 100644
--- a/src/lib/yieldxyz/types.ts
+++ b/src/lib/yieldxyz/types.ts
@@ -313,6 +313,8 @@ export type ProviderDto = {
logoURI: string
description?: string
documentation?: string
+ website?: string
+ references?: string[]
}
export type ProvidersResponse = {
diff --git a/src/lib/yieldxyz/utils.test.ts b/src/lib/yieldxyz/utils.test.ts
index 49cd1f1f204..1935ac4258c 100644
--- a/src/lib/yieldxyz/utils.test.ts
+++ b/src/lib/yieldxyz/utils.test.ts
@@ -5,6 +5,8 @@ import type { AugmentedYieldDto, ValidatorDto } from './types'
import {
ensureValidatorApr,
getTransactionButtonText,
+ getYieldActionLabelKeys,
+ isStakingYieldType,
resolveYieldInputAssetIcon,
searchValidators,
searchYields,
@@ -243,3 +245,73 @@ describe('ensureValidatorApr', () => {
expect(result.rewardRate?.components).toHaveLength(1)
})
})
+
+describe('getYieldActionLabelKeys', () => {
+ it('should return stake/unstake for staking yield types', () => {
+ expect(getYieldActionLabelKeys('staking')).toEqual({
+ enter: 'defi.stake',
+ exit: 'defi.unstake',
+ })
+ expect(getYieldActionLabelKeys('native-staking')).toEqual({
+ enter: 'defi.stake',
+ exit: 'defi.unstake',
+ })
+ expect(getYieldActionLabelKeys('pooled-staking')).toEqual({
+ enter: 'defi.stake',
+ exit: 'defi.unstake',
+ })
+ expect(getYieldActionLabelKeys('liquid-staking')).toEqual({
+ enter: 'defi.stake',
+ exit: 'defi.unstake',
+ })
+ })
+
+ it('should return restake/unstake for restaking yield types', () => {
+ expect(getYieldActionLabelKeys('restaking')).toEqual({
+ enter: 'yieldXYZ.actions.restake',
+ exit: 'defi.unstake',
+ })
+ })
+
+ it('should return deposit/withdraw for vault yield types', () => {
+ expect(getYieldActionLabelKeys('vault')).toEqual({
+ enter: 'common.deposit',
+ exit: 'common.withdraw',
+ })
+ })
+
+ it('should return deposit/withdraw for lending yield types', () => {
+ expect(getYieldActionLabelKeys('lending')).toEqual({
+ enter: 'common.deposit',
+ exit: 'common.withdraw',
+ })
+ })
+
+ it('should return deposit/withdraw for unknown yield types', () => {
+ expect(getYieldActionLabelKeys('unknown')).toEqual({
+ enter: 'common.deposit',
+ exit: 'common.withdraw',
+ })
+ expect(getYieldActionLabelKeys('')).toEqual({
+ enter: 'common.deposit',
+ exit: 'common.withdraw',
+ })
+ })
+})
+
+describe('isStakingYieldType', () => {
+ it('should return true for staking-related yield types', () => {
+ expect(isStakingYieldType('staking')).toBe(true)
+ expect(isStakingYieldType('native-staking')).toBe(true)
+ expect(isStakingYieldType('pooled-staking')).toBe(true)
+ expect(isStakingYieldType('liquid-staking')).toBe(true)
+ expect(isStakingYieldType('restaking')).toBe(true)
+ })
+
+ it('should return false for non-staking yield types', () => {
+ expect(isStakingYieldType('vault')).toBe(false)
+ expect(isStakingYieldType('lending')).toBe(false)
+ expect(isStakingYieldType('unknown')).toBe(false)
+ expect(isStakingYieldType('')).toBe(false)
+ })
+})
diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts
index 2b020763e99..4e02ee855a6 100644
--- a/src/lib/yieldxyz/utils.ts
+++ b/src/lib/yieldxyz/utils.ts
@@ -155,3 +155,52 @@ export const ensureValidatorApr = (validator: ValidatorDto): ValidatorDto =>
components: validator.rewardRate?.components ?? [],
},
}
+
+/**
+ * Translation keys for yield action labels based on yield type.
+ * Enter = deposit/stake/restake action
+ * Exit = withdraw/unstake action
+ */
+export type YieldActionLabelKeys = {
+ enter: string
+ exit: string
+}
+
+/**
+ * Gets the appropriate translation keys for yield actions based on yield type.
+ *
+ * Yield types and their terminology:
+ * - staking, native-staking, pooled-staking, liquid-staking → Stake/Unstake
+ * - restaking → Restake/Unstake
+ * - vault, lending, and others → Deposit/Withdraw
+ */
+export const getYieldActionLabelKeys = (yieldType: string): YieldActionLabelKeys => {
+ switch (yieldType) {
+ case 'staking':
+ case 'native-staking':
+ case 'pooled-staking':
+ case 'liquid-staking':
+ return { enter: 'defi.stake', exit: 'defi.unstake' }
+ case 'restaking':
+ return { enter: 'yieldXYZ.actions.restake', exit: 'defi.unstake' }
+ case 'vault':
+ case 'lending':
+ default:
+ return { enter: 'common.deposit', exit: 'common.withdraw' }
+ }
+}
+
+const STAKING_YIELD_TYPES = new Set([
+ 'staking',
+ 'native-staking',
+ 'pooled-staking',
+ 'liquid-staking',
+ 'restaking',
+])
+
+/**
+ * Checks if a yield type uses staking terminology (stake/unstake).
+ */
+export const isStakingYieldType = (yieldType: string): boolean => {
+ return STAKING_YIELD_TYPES.has(yieldType)
+}
diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx
index 06bb993ae67..884871fcd87 100644
--- a/src/pages/Yields/YieldDetail.tsx
+++ b/src/pages/Yields/YieldDetail.tsx
@@ -1,5 +1,8 @@
-import { Box, Button, Container, Flex, Heading, Text } from '@chakra-ui/react'
-import { memo, useEffect, useMemo } from 'react'
+import { ArrowBackIcon } from '@chakra-ui/icons'
+import type { ResponsiveValue } from '@chakra-ui/react'
+import { Box, Button, Container, Flex, Heading, IconButton, Stack, Text } from '@chakra-ui/react'
+import type { Property } from 'csstype'
+import { memo, useCallback, useEffect, useMemo } from 'react'
import { useTranslate } from 'react-polyglot'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
@@ -18,10 +21,15 @@ import {
SHAPESHIFT_VALIDATOR_NAME,
SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID,
} from '@/lib/yieldxyz/constants'
+import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName'
import { YieldBalanceType } from '@/lib/yieldxyz/types'
+import { YieldAvailableToDeposit } from '@/pages/Yields/components/YieldAvailableToDeposit'
import { YieldHero } from '@/pages/Yields/components/YieldHero'
+import { YieldInfoCard } from '@/pages/Yields/components/YieldInfoCard'
import { YieldManager } from '@/pages/Yields/components/YieldManager'
import { YieldPositionCard } from '@/pages/Yields/components/YieldPositionCard'
+import { YieldProviderInfo } from '@/pages/Yields/components/YieldProviderInfo'
+import { YieldRelatedMarkets } from '@/pages/Yields/components/YieldRelatedMarkets'
import { YieldStats } from '@/pages/Yields/components/YieldStats'
import { useYieldAccountSync } from '@/pages/Yields/hooks/useYieldAccountSync'
import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances'
@@ -29,11 +37,21 @@ import { useYield } from '@/react-queries/queries/yieldxyz/useYield'
import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders'
import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators'
import {
+ selectMarketDataByAssetIdUserCurrency,
selectPortfolioAccountIdsByAssetIdFilter,
selectUserCurrencyToUsdRate,
} from '@/state/slices/selectors'
import { useAppSelector } from '@/state/store'
+const backIcon =
+
+const layoutDirection: ResponsiveValue = {
+ base: 'column',
+ lg: 'row',
+}
+
+const actionColumnMaxWidth = { base: '100%', lg: '500px' }
+
export const YieldDetail = memo(() => {
const { yieldId } = useParams<{ yieldId: string }>()
const [searchParams] = useSearchParams()
@@ -44,11 +62,15 @@ export const YieldDetail = memo(() => {
const { data: yieldItem, isLoading, error } = useYield(yieldId ?? '')
- const selectorAssetId = useMemo(() => {
- if (yieldItem?.token.assetId) return yieldItem.token.assetId
- if (yieldItem?.inputTokens?.[0]?.assetId) return yieldItem.inputTokens[0].assetId
- return undefined
- }, [yieldItem?.inputTokens, yieldItem?.token.assetId])
+ const selectorAssetId =
+ yieldItem?.token.assetId ?? yieldItem?.inputTokens?.[0]?.assetId ?? undefined
+
+ const inputTokenAssetId = yieldItem?.inputTokens[0]?.assetId ?? ''
+ const inputTokenMarketData = useAppSelector(state =>
+ selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId),
+ )
+
+ const handleBack = useCallback(() => navigate('/yields'), [navigate])
const availableAccounts = useAppSelector(state =>
selectorAssetId
@@ -65,9 +87,10 @@ export const YieldDetail = memo(() => {
const showAccountSelector = isYieldMultiAccountEnabled && availableAccounts.length > 1
const balanceAccountIds = useMemo(() => {
- if (!isYieldMultiAccountEnabled)
- return availableAccounts.length > 0 ? availableAccounts : undefined
- return selectedAccountId ? [selectedAccountId] : undefined
+ if (isYieldMultiAccountEnabled) {
+ return selectedAccountId ? [selectedAccountId] : undefined
+ }
+ return availableAccounts.length > 0 ? availableAccounts : undefined
}, [isYieldMultiAccountEnabled, selectedAccountId, availableAccounts])
const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances({
@@ -76,14 +99,12 @@ export const YieldDetail = memo(() => {
const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined
const isBalancesLoading = !allBalancesData && isBalancesFetching
- const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams])
- const defaultValidator = useMemo(
- () =>
- yieldItem?.chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] : undefined,
- [yieldItem?.chainId],
- )
+ const validatorParam = searchParams.get('validator')
+ const defaultValidator = yieldItem?.chainId
+ ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]
+ : undefined
+
const selectedValidatorAddress = useMemo(() => {
- // For native staking with hardcoded defaults, always use the default validator (ignore URL param)
if (
yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID ||
yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID ||
@@ -95,10 +116,7 @@ export const YieldDetail = memo(() => {
}, [yieldId, validatorParam, defaultValidator])
const isStaking = yieldItem?.mechanics.type === 'staking'
- const shouldFetchValidators = useMemo(
- () => isStaking && yieldItem?.mechanics.requiresValidatorSelection,
- [isStaking, yieldItem?.mechanics.requiresValidatorSelection],
- )
+ const shouldFetchValidators = isStaking && yieldItem?.mechanics.requiresValidatorSelection
const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators)
const { data: yieldProviders } = useYieldProviders()
@@ -115,7 +133,14 @@ export const YieldDetail = memo(() => {
}
if (!isStaking && yieldItem) {
const provider = yieldProviders?.[yieldItem.providerId]
- if (provider) return { name: provider.name, logoURI: provider.logoURI }
+ if (provider) {
+ return {
+ name: provider.name,
+ logoURI: provider.logoURI,
+ description: provider.description,
+ documentation: provider.references?.[0] ?? provider.website,
+ }
+ }
}
return null
}, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders])
@@ -125,8 +150,7 @@ export const YieldDetail = memo(() => {
const isNativeStaking =
yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection
if (isNativeStaking) return translate('yieldXYZ.nativeStaking')
- // For non-native staking, use token symbol (consistent with cards)
- return yieldItem.token.symbol
+ return getYieldDisplayName(yieldItem)
}, [yieldItem, translate])
const userBalances = useMemo(() => {
@@ -163,10 +187,7 @@ export const YieldDetail = memo(() => {
if (!yieldId) navigate('/yields')
}, [yieldId, navigate])
- const isModalOpen = useMemo(() => {
- const modal = searchParams.get('modal')
- return modal === 'yield'
- }, [searchParams])
+ const isModalOpen = searchParams.get('modal') === 'yield'
const loadingElement = useMemo(
() => (
@@ -206,47 +227,118 @@ export const YieldDetail = memo(() => {
return (
- {showAccountSelector && selectorAssetId && (
- <>
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
-
-
-
+
-
+
+ {showAccountSelector && selectorAssetId && (
+
+ )}
+
+
+
+ {showAccountSelector && selectorAssetId && (
+
+
+
+ )}
+
+
+
+
+
+ {!isStaking && validatorOrProvider && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {!isStaking && validatorOrProvider && (
+
+ )}
+
+
+
+
+
+
+
+
+
{isModalOpen && }
diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx
index a1fd1c765f9..e29bfc1d100 100644
--- a/src/pages/Yields/components/YieldActionModal.tsx
+++ b/src/pages/Yields/components/YieldActionModal.tsx
@@ -16,7 +16,7 @@ import {
SHAPESHIFT_VALIDATOR_NAME,
} from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
-import { getTransactionButtonText } from '@/lib/yieldxyz/utils'
+import { getTransactionButtonText, isStakingYieldType } from '@/lib/yieldxyz/utils'
import { GradientApy } from '@/pages/Yields/components/GradientApy'
import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList'
import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow'
@@ -89,9 +89,14 @@ export const YieldActionModal = memo(function YieldActionModal({
accountId,
})
+ const isStaking = useMemo(
+ () => isStakingYieldType(yieldItem.mechanics.type),
+ [yieldItem.mechanics.type],
+ )
+
const shouldFetchValidators = useMemo(
- () => yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection,
- [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection],
+ () => isStaking && yieldItem.mechanics.requiresValidatorSelection,
+ [isStaking, yieldItem.mechanics.requiresValidatorSelection],
)
const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators)
@@ -106,7 +111,7 @@ export const YieldActionModal = memo(function YieldActionModal({
)
const vaultMetadata = useMemo(() => {
- if (yieldItem.mechanics.type === 'staking' && validatorAddress) {
+ if (isStaking && validatorAddress) {
const validator = validators?.find(v => v.address === validatorAddress)
if (validator) return { name: validator.name, logoURI: validator.logoURI }
if (validatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) {
@@ -117,7 +122,15 @@ export const YieldActionModal = memo(function YieldActionModal({
const provider = providers?.[yieldItem.providerId]
if (provider) return { name: provider.name, logoURI: provider.logoURI }
return { name: 'Vault', logoURI: yieldItem.metadata.logoURI }
- }, [yieldItem, validatorAddress, validatorName, validatorLogoURI, validators, providers])
+ }, [
+ isStaking,
+ yieldItem,
+ validatorAddress,
+ validatorName,
+ validatorLogoURI,
+ validators,
+ providers,
+ ])
const chainId = useMemo(() => yieldItem.chainId ?? '', [yieldItem.chainId])
const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, chainId))
@@ -152,14 +165,9 @@ export const YieldActionModal = memo(function YieldActionModal({
[amount, yieldItem.rewardRate.total, marketData?.price],
)
- const isStaking = useMemo(
- () => yieldItem.mechanics.type === 'staking',
- [yieldItem.mechanics.type],
- )
-
const showValidatorRow = useMemo(
- () => isStaking && vaultMetadata.name !== 'Vault',
- [isStaking, vaultMetadata.name],
+ () => isStaking && Boolean(validatorAddress),
+ [isStaking, validatorAddress],
)
const isButtonDisabled = useMemo(
@@ -284,7 +292,7 @@ export const YieldActionModal = memo(function YieldActionModal({
)}
- {!isStaking && (
+ {!showValidatorRow && (
{translate('yieldXYZ.provider')}
@@ -320,7 +328,6 @@ export const YieldActionModal = memo(function YieldActionModal({
estimatedEarningsAmount,
estimatedEarningsFiat,
showValidatorRow,
- isStaking,
vaultMetadata.logoURI,
vaultMetadata.name,
feeAsset,
diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx
index cd1813b0f0c..c3dcede91b2 100644
--- a/src/pages/Yields/components/YieldActivePositions.tsx
+++ b/src/pages/Yields/components/YieldActivePositions.tsx
@@ -24,8 +24,11 @@ import { bnOrZero } from '@/lib/bignumber/bignumber'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries/yieldxyz/useAllYieldBalances'
import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders'
-import { accountIdToLabel } from '@/state/slices/portfolioSlice/utils'
-import { selectAssetById, selectUserCurrencyToUsdRate } from '@/state/slices/selectors'
+import {
+ selectAccountNumberByAccountId,
+ selectAssetById,
+ selectUserCurrencyToUsdRate,
+} from '@/state/slices/selectors'
import { useAppSelector } from '@/state/store'
type AccountBalanceInfo = {
@@ -83,6 +86,35 @@ export const YieldActivePositions = memo(
[providers],
)
+ const uniqueAccountIds = useMemo((): AccountId[] => {
+ if (!balancesByYieldId) return []
+ const accountIdSet = new Set()
+ for (const balances of Object.values(balancesByYieldId)) {
+ for (const balance of balances) {
+ if (balance.accountId) accountIdSet.add(balance.accountId)
+ }
+ }
+ return Array.from(accountIdSet)
+ }, [balancesByYieldId])
+
+ const accountNumberByAccountId = useAppSelector(state => {
+ const mapping: Record = {}
+ for (const accountId of uniqueAccountIds) {
+ mapping[accountId] = selectAccountNumberByAccountId(state, { accountId })
+ }
+ return mapping
+ })
+
+ const getAccountLabel = useCallback(
+ (accountId: AccountId) => {
+ const accountNumber = accountNumberByAccountId[accountId]
+ return accountNumber !== undefined
+ ? translate('accounts.accountNumber', { accountNumber })
+ : translate('accounts.account')
+ },
+ [accountNumberByAccountId, translate],
+ )
+
const accountPositions = useMemo((): AccountYieldPosition[] => {
if (!balancesByYieldId || !asset) return []
@@ -122,7 +154,7 @@ export const YieldActivePositions = memo(
for (const [accountId, balanceInfo] of accountBalances) {
positions.push({
accountId,
- accountLabel: accountIdToLabel(accountId),
+ accountLabel: getAccountLabel(accountId),
yieldItem,
providerId: yieldItem.providerId,
providerLogo: getProviderLogo(yieldItem.providerId),
@@ -137,7 +169,7 @@ export const YieldActivePositions = memo(
}
return positions.sort((a, b) => bnOrZero(b.totalUsd).minus(a.totalUsd).toNumber())
- }, [balancesByYieldId, yields, asset, getProviderLogo])
+ }, [balancesByYieldId, yields, asset, getProviderLogo, getAccountLabel])
const handlePositionClick = useCallback(
({ yieldId, accountId, validatorAddress }: HandlePositionClickArgs) => {
diff --git a/src/pages/Yields/components/YieldAvailableToDeposit.tsx b/src/pages/Yields/components/YieldAvailableToDeposit.tsx
new file mode 100644
index 00000000000..cf998872508
--- /dev/null
+++ b/src/pages/Yields/components/YieldAvailableToDeposit.tsx
@@ -0,0 +1,109 @@
+import { InfoOutlineIcon } from '@chakra-ui/icons'
+import { Box, Card, CardBody, Flex, Heading, HStack, Text, Tooltip, VStack } from '@chakra-ui/react'
+import { memo, useMemo } from 'react'
+import { useTranslate } from 'react-polyglot'
+
+import { Amount } from '@/components/Amount/Amount'
+import { bnOrZero } from '@/lib/bignumber/bignumber'
+import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
+import { selectPortfolioCryptoBalanceBaseUnitByFilter } from '@/state/slices/selectors'
+import { useAppSelector } from '@/state/store'
+
+type YieldAvailableToDepositProps = {
+ yieldItem: AugmentedYieldDto
+ inputTokenMarketData: { price?: string } | undefined
+}
+
+export const YieldAvailableToDeposit = memo(
+ ({ yieldItem, inputTokenMarketData }: YieldAvailableToDepositProps) => {
+ const translate = useTranslate()
+
+ const inputToken = yieldItem.inputTokens[0]
+ const inputTokenAssetId = inputToken?.assetId ?? ''
+ const inputTokenPrecision = inputToken?.decimals
+
+ const availableBalanceBaseUnit = useAppSelector(state =>
+ selectPortfolioCryptoBalanceBaseUnitByFilter(state, { assetId: inputTokenAssetId }),
+ )
+
+ const availableBalance = useMemo(
+ () =>
+ inputTokenPrecision
+ ? bnOrZero(availableBalanceBaseUnit).shiftedBy(-inputTokenPrecision)
+ : bnOrZero(0),
+ [availableBalanceBaseUnit, inputTokenPrecision],
+ )
+
+ const availableBalanceFiat = useMemo(
+ () => availableBalance.times(bnOrZero(inputTokenMarketData?.price)),
+ [availableBalance, inputTokenMarketData?.price],
+ )
+
+ const potentialYearlyEarningsFiat = useMemo(
+ () => availableBalanceFiat.times(yieldItem.rewardRate.total),
+ [availableBalanceFiat, yieldItem.rewardRate.total],
+ )
+
+ const hasAvailableBalance = availableBalance.gt(0)
+
+ if (!inputTokenPrecision) return null
+
+ const tooltipLabel = translate('yieldXYZ.availableToDepositTooltip', {
+ symbol: yieldItem.token.symbol,
+ })
+
+ if (!hasAvailableBalance) return null
+
+ return (
+
+
+
+
+
+
+ {translate('yieldXYZ.availableToDeposit')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {potentialYearlyEarningsFiat.gt(0) && (
+
+
+ {translate('yieldXYZ.potentialEarnings')}
+
+
+
+ )}
+
+
+
+ )
+ },
+)
diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx
index e486c288a46..3ea4266d513 100644
--- a/src/pages/Yields/components/YieldEnterModal.tsx
+++ b/src/pages/Yields/components/YieldEnterModal.tsx
@@ -29,9 +29,10 @@ import {
SHAPESHIFT_VALIDATOR_NAME,
} from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
-import { getTransactionButtonText } from '@/lib/yieldxyz/utils'
+import { getTransactionButtonText, isStakingYieldType } from '@/lib/yieldxyz/utils'
import { GradientApy } from '@/pages/Yields/components/GradientApy'
import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList'
+import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers'
import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess'
import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow'
import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders'
@@ -179,7 +180,7 @@ export const YieldEnterModal = memo(
const { data: providers } = useYieldProviders()
- const isStaking = yieldItem.mechanics.type === 'staking'
+ const isStaking = isStakingYieldType(yieldItem.mechanics.type)
const selectedValidatorMetadata = useMemo(() => {
if (!isStaking || !selectedValidatorAddress) return null
@@ -634,6 +635,10 @@ export const YieldEnterModal = memo(
)}
{statsContent}
+
{stepsToShow.length > 0 && }
)}
diff --git a/src/pages/Yields/components/YieldExplainers.tsx b/src/pages/Yields/components/YieldExplainers.tsx
new file mode 100644
index 00000000000..d1657b526bf
--- /dev/null
+++ b/src/pages/Yields/components/YieldExplainers.tsx
@@ -0,0 +1,110 @@
+import { InfoIcon } from '@chakra-ui/icons'
+import { Box, HStack, Icon, Text, VStack } from '@chakra-ui/react'
+import type { ReactNode } from 'react'
+import { memo, useMemo } from 'react'
+import { FaGift } from 'react-icons/fa'
+import { MdSwapHoriz } from 'react-icons/md'
+import { useTranslate } from 'react-polyglot'
+
+import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
+
+const swapIcon =
+const giftIcon =
+const infoIcon =
+
+type ExplainerItem = {
+ icon: ReactNode
+ textKey: string
+}
+
+const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => {
+ const yieldType = selectedYield.mechanics.type
+ const outputTokenSymbol = selectedYield.outputToken?.symbol
+
+ switch (yieldType) {
+ case 'liquid-staking':
+ return [
+ {
+ icon: swapIcon,
+ textKey: outputTokenSymbol
+ ? 'earn.explainers.liquidStakingReceive'
+ : 'earn.explainers.liquidStakingTrade',
+ },
+ { icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule' },
+ { icon: infoIcon, textKey: 'earn.explainers.liquidStakingWithdraw' },
+ ]
+ case 'native-staking':
+ case 'pooled-staking':
+ case 'staking':
+ return [
+ { icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule' },
+ { icon: infoIcon, textKey: 'earn.explainers.stakingUnbonding' },
+ ]
+ case 'restaking':
+ return [
+ { icon: giftIcon, textKey: 'earn.explainers.restakingYield' },
+ { icon: infoIcon, textKey: 'earn.explainers.restakingWithdraw' },
+ ]
+ case 'vault':
+ return [
+ { icon: giftIcon, textKey: 'earn.explainers.vaultYield' },
+ { icon: infoIcon, textKey: 'earn.explainers.vaultWithdraw' },
+ ]
+ case 'lending':
+ return [
+ { icon: giftIcon, textKey: 'earn.explainers.lendingYield' },
+ { icon: infoIcon, textKey: 'earn.explainers.lendingWithdraw' },
+ ]
+ default:
+ return []
+ }
+}
+
+type YieldExplainersProps = {
+ selectedYield: AugmentedYieldDto
+ sellAssetSymbol?: string
+}
+
+export const YieldExplainers = memo(({ selectedYield, sellAssetSymbol }: YieldExplainersProps) => {
+ const translate = useTranslate()
+
+ const explainers = useMemo(() => getYieldExplainers(selectedYield), [selectedYield])
+
+ const rewardSchedule = selectedYield.mechanics.rewardSchedule
+ const outputSymbol = selectedYield.outputToken?.symbol
+
+ const cooldownDays = useMemo(() => {
+ const seconds = selectedYield.mechanics.cooldownPeriod?.seconds
+ if (!seconds) return undefined
+ return Math.ceil(seconds / 86400)
+ }, [selectedYield.mechanics.cooldownPeriod?.seconds])
+
+ const symbol = outputSymbol ?? sellAssetSymbol ?? ''
+
+ const translatedExplainers = useMemo(() => {
+ if (explainers.length === 0) return []
+ return explainers.map(explainer => ({
+ icon: explainer.icon,
+ text: translate(explainer.textKey, {
+ symbol,
+ schedule: rewardSchedule ?? '',
+ days: cooldownDays ?? '',
+ }),
+ }))
+ }, [explainers, translate, symbol, rewardSchedule, cooldownDays])
+
+ if (translatedExplainers.length === 0) return null
+
+ return (
+
+ {translatedExplainers.map((explainer, index) => (
+
+ {explainer.icon}
+
+ {explainer.text}
+
+
+ ))}
+
+ )
+})
diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx
index 1acf96a12a1..8ceb07a2a00 100644
--- a/src/pages/Yields/components/YieldFilters.tsx
+++ b/src/pages/Yields/components/YieldFilters.tsx
@@ -21,7 +21,14 @@ import { useTranslate } from 'react-polyglot'
import { AssetIcon } from '@/components/AssetIcon'
import { ChainIcon } from '@/components/ChainMenu'
-export type SortOption = 'apy-desc' | 'apy-asc' | 'tvl-desc' | 'tvl-asc' | 'name-asc' | 'name-desc'
+export type SortOption =
+ | 'yearly-return-desc'
+ | 'apy-desc'
+ | 'apy-asc'
+ | 'tvl-desc'
+ | 'tvl-asc'
+ | 'name-asc'
+ | 'name-desc'
export type NetworkOption = {
id: string
@@ -144,6 +151,10 @@ export const YieldFilters = memo(
const sortOptions = useMemo(
() => [
+ {
+ value: 'yearly-return-desc' as const,
+ label: translate('yieldXYZ.bestReturn'),
+ },
{ value: 'apy-desc' as const, label: translate('yieldXYZ.highestApy') },
{ value: 'apy-asc' as const, label: translate('yieldXYZ.lowestApy') },
{ value: 'tvl-desc' as const, label: translate('yieldXYZ.highestTvl') },
diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx
index cfa9923af65..ead0f0557cf 100644
--- a/src/pages/Yields/components/YieldForm.tsx
+++ b/src/pages/Yields/components/YieldForm.tsx
@@ -24,9 +24,14 @@ import {
} from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
import { YieldBalanceType } from '@/lib/yieldxyz/types'
-import { getTransactionButtonText } from '@/lib/yieldxyz/utils'
+import {
+ getTransactionButtonText,
+ getYieldActionLabelKeys,
+ isStakingYieldType,
+} from '@/lib/yieldxyz/utils'
import { GradientApy } from '@/pages/Yields/components/GradientApy'
import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList'
+import { YieldExplainers } from '@/pages/Yields/components/YieldExplainers'
import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess'
import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow'
import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances'
@@ -57,36 +62,6 @@ type YieldFormProps = {
const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const
-const getEnterActionTextKey = (yieldType: string | undefined): string => {
- switch (yieldType) {
- case 'native-staking':
- case 'pooled-staking':
- case 'liquid-staking':
- case 'staking':
- return 'defi.stake'
- case 'vault':
- return 'common.deposit'
- case 'lending':
- return 'common.supply'
- default:
- return 'common.deposit'
- }
-}
-
-const getExitActionTextKey = (yieldType: string | undefined): string => {
- switch (yieldType) {
- case 'native-staking':
- case 'pooled-staking':
- case 'liquid-staking':
- case 'staking':
- return 'defi.unstake'
- case 'vault':
- case 'lending':
- default:
- return 'common.withdraw'
- }
-}
-
const INPUT_LENGTH_BREAKPOINTS = {
FOR_XS_FONT: 22,
FOR_SM_FONT: 14,
@@ -223,7 +198,7 @@ export const YieldForm = memo(
const { data: providers } = useYieldProviders()
- const isStaking = yieldItem.mechanics.type === 'staking'
+ const isStaking = isStakingYieldType(yieldItem.mechanics.type)
const selectedValidatorMetadata = useMemo(() => {
if (!isStaking || !selectedValidatorAddress) return null
@@ -473,14 +448,12 @@ export const YieldForm = memo(
const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED')
if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title)
- const yieldType = yieldItem.mechanics.type
+ const actionLabelKeys = getYieldActionLabelKeys(yieldItem.mechanics.type)
if (action === 'enter') {
- const actionKey = getEnterActionTextKey(yieldType)
- return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}`
+ return `${translate(actionLabelKeys.enter)} ${inputTokenAsset?.symbol ?? ''}`
}
if (action === 'exit') {
- const actionKey = getExitActionTextKey(yieldType)
- return `${translate(actionKey)} ${inputTokenAsset?.symbol ?? ''}`
+ return `${translate(actionLabelKeys.exit)} ${inputTokenAsset?.symbol ?? ''}`
}
if (action === 'claim') {
return `${translate('common.claim')} ${claimableToken?.symbol ?? ''}`
@@ -749,6 +722,9 @@ export const YieldForm = memo(
)}
{!isClaimAction && statsContent}
+ {!isClaimAction && (
+
+ )}
{stepsToShow.length > 0 && }
diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx
index 6d1552c1f96..0c4c4d4244b 100644
--- a/src/pages/Yields/components/YieldHero.tsx
+++ b/src/pages/Yields/components/YieldHero.tsx
@@ -1,12 +1,13 @@
-import { ArrowBackIcon, ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons'
+import { ArrowDownIcon, ArrowUpIcon, ExternalLinkIcon } from '@chakra-ui/icons'
import {
+ Alert,
+ AlertDescription,
+ AlertIcon,
Avatar,
Badge,
- Box,
Button,
- Flex,
HStack,
- IconButton,
+ Link,
Text,
VStack,
} from '@chakra-ui/react'
@@ -21,15 +22,16 @@ import { ChainIcon } from '@/components/ChainMenu'
import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter'
import { bnOrZero } from '@/lib/bignumber/bignumber'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
-import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils'
+import { getYieldActionLabelKeys, resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils'
-const backIcon =
const enterIcon =
const exitIcon =
type ValidatorOrProviderInfo = {
name: string
logoURI?: string
+ description?: string
+ documentation?: string
} | null
type YieldHeroProps = {
@@ -52,17 +54,12 @@ export const YieldHero = memo(
const translate = useTranslate()
const { location } = useBrowserRouter()
- const iconSource = useMemo(() => resolveYieldInputAssetIcon(yieldItem), [yieldItem])
- const apy = useMemo(
- () => bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2),
- [yieldItem.rewardRate.total],
- )
- const hasExitBalance = useMemo(() => bnOrZero(userBalanceCrypto).gt(0), [userBalanceCrypto])
+ const iconSource = resolveYieldInputAssetIcon(yieldItem)
+ const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2)
+ const hasExitBalance = bnOrZero(userBalanceCrypto).gt(0)
const [searchParams] = useSearchParams()
- const validator = useMemo(() => searchParams.get('validator'), [searchParams])
-
- const handleBack = useCallback(() => navigate('/yields'), [navigate])
+ const validator = searchParams.get('validator')
const handleAction = useCallback(
(action: 'enter' | 'exit') => {
@@ -81,64 +78,42 @@ export const YieldHero = memo(
const handleEnter = useCallback(() => handleAction('enter'), [handleAction])
const handleExit = useCallback(() => handleAction('exit'), [handleAction])
- const enterLabel = useMemo(
- () =>
- yieldItem.mechanics.type === 'staking'
- ? translate('defi.stake')
- : translate('common.deposit'),
- [yieldItem.mechanics.type, translate],
- )
-
- const exitLabel = useMemo(
- () =>
- yieldItem.mechanics.type === 'staking'
- ? translate('defi.unstake')
- : translate('common.withdraw'),
- [yieldItem.mechanics.type, translate],
- )
+ const actionLabelKeys = getYieldActionLabelKeys(yieldItem.mechanics.type)
+ const enterLabel = translate(actionLabelKeys.enter)
+ const exitLabel = translate(actionLabelKeys.exit)
const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol
- const stackedIconElement = useMemo(() => {
- const assetIcon = iconSource.assetId ? (
-
- ) : (
-
- )
-
- const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId
-
- if (!hasOverlay) return assetIcon
-
+ const descriptionSection = useMemo(() => {
+ const docUrl = validatorOrProvider?.documentation ?? yieldItem.metadata.documentation
+ const description = validatorOrProvider?.description ?? yieldItem.metadata.description
+ if (!description && !docUrl) return null
return (
-
- {assetIcon}
- {validatorOrProvider?.logoURI ? (
-
- ) : yieldItem.chainId ? (
-
+ {description && (
+
+ {description}
+
+ )}
+ {docUrl && (
+
-
-
- ) : null}
-
+
+
+ )}
+
)
- }, [iconSource, validatorOrProvider, yieldItem.chainId])
+ }, [
+ validatorOrProvider?.description,
+ validatorOrProvider?.documentation,
+ yieldItem.metadata.documentation,
+ yieldItem.metadata.description,
+ ])
return (
-
-
-
- {yieldTitle}
-
-
-
+
+ {yieldTitle}
+
+
+ {yieldItem.metadata.deprecated && (
+
+
+
+ {translate('yieldXYZ.deprecatedDescription')}
+
+
+ )}
-
- {stackedIconElement}
-
- {validatorOrProvider?.name ?? yieldItem.token.symbol}
-
+ {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && (
+
+
+
+ {translate('yieldXYZ.underMaintenanceDescription')}
+
+
+ )}
+
+
+
+ {iconSource.assetId ? (
+
+ ) : (
+
+ )}
+
+ {yieldItem.token.symbol}
+
+
{yieldItem.chainId && (
-
-
-
+
+
+
{yieldItem.network}
)}
+ {validatorOrProvider?.name && (
+
+ {validatorOrProvider.logoURI && (
+
+ )}
+
+ {validatorOrProvider.name}
+
+
+ )}
- {yieldItem.metadata.description && (
-
- {yieldItem.metadata.description}
-
- )}
+ {descriptionSection}
-
+
- {yieldItem.token.symbol}
-
-
-
+
@@ -231,20 +219,20 @@ export const YieldHero = memo(
>
{enterLabel}
-
- {exitLabel}
-
+ {hasExitBalance && (
+
+ {exitLabel}
+
+ )}
)
diff --git a/src/pages/Yields/components/YieldInfoCard.tsx b/src/pages/Yields/components/YieldInfoCard.tsx
new file mode 100644
index 00000000000..760c704f18e
--- /dev/null
+++ b/src/pages/Yields/components/YieldInfoCard.tsx
@@ -0,0 +1,190 @@
+import {
+ Alert,
+ AlertDescription,
+ AlertIcon,
+ Avatar,
+ Badge,
+ Box,
+ Card,
+ CardBody,
+ Flex,
+ Heading,
+ HStack,
+ Text,
+ VStack,
+} from '@chakra-ui/react'
+import { memo } from 'react'
+import { useTranslate } from 'react-polyglot'
+
+import { AssetIcon } from '@/components/AssetIcon'
+import { ChainIcon } from '@/components/ChainMenu'
+import { bnOrZero } from '@/lib/bignumber/bignumber'
+import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName'
+import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
+import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils'
+import { GradientApy } from '@/pages/Yields/components/GradientApy'
+
+type ValidatorOrProviderInfo = {
+ name: string
+ logoURI?: string
+ description?: string
+ documentation?: string
+} | null
+
+type YieldInfoCardProps = {
+ yieldItem: AugmentedYieldDto
+ validatorOrProvider: ValidatorOrProviderInfo
+ titleOverride?: string
+}
+
+export const YieldInfoCard = memo(
+ ({ yieldItem, validatorOrProvider, titleOverride }: YieldInfoCardProps) => {
+ const translate = useTranslate()
+
+ const iconSource = resolveYieldInputAssetIcon(yieldItem)
+ const apy = bnOrZero(yieldItem.rewardRate.total).times(100).toFixed(2)
+ const yieldTitle = titleOverride ?? getYieldDisplayName(yieldItem)
+ const type = yieldItem.mechanics.type
+ const description = yieldItem.metadata.description
+
+ const assetIcon = iconSource.assetId ? (
+
+ ) : (
+
+ )
+
+ const hasOverlay = validatorOrProvider?.logoURI || yieldItem.chainId
+
+ const stackedIconElement = !hasOverlay ? (
+ assetIcon
+ ) : (
+
+ {assetIcon}
+ {validatorOrProvider?.logoURI ? (
+
+ ) : yieldItem.chainId ? (
+
+
+
+ ) : null}
+
+ )
+
+ return (
+
+
+
+ {yieldItem.metadata.deprecated && (
+
+
+
+ {translate('yieldXYZ.deprecatedDescription')}
+
+
+ )}
+
+ {yieldItem.metadata.underMaintenance && !yieldItem.metadata.deprecated && (
+
+
+
+ {translate('yieldXYZ.underMaintenanceDescription')}
+
+
+ )}
+
+
+ {stackedIconElement}
+
+ {yieldTitle}
+
+
+
+
+
+
+ {apy}% {translate('common.apy')}
+
+
+
+ {type}
+
+
+
+
+
+ {iconSource.assetId ? (
+
+ ) : (
+
+ )}
+
+ {yieldItem.token.symbol}
+
+
+ {yieldItem.chainId && (
+
+
+
+ {yieldItem.network}
+
+
+ )}
+ {validatorOrProvider?.name && (
+
+ {validatorOrProvider.logoURI && (
+
+ )}
+
+ {validatorOrProvider.name}
+
+
+ )}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ )
+ },
+)
diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx
index 22049d633c6..1a239a34d29 100644
--- a/src/pages/Yields/components/YieldItem.tsx
+++ b/src/pages/Yields/components/YieldItem.tsx
@@ -1,6 +1,7 @@
import {
Avatar,
AvatarGroup,
+ Badge,
Box,
Card,
CardBody,
@@ -13,6 +14,7 @@ import {
StatLabel,
StatNumber,
Text,
+ Tooltip,
} from '@chakra-ui/react'
import type BigNumber from 'bignumber.js'
import { memo, useCallback, useMemo } from 'react'
@@ -22,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
import { Amount } from '@/components/Amount/Amount'
import { AssetIcon } from '@/components/AssetIcon'
import { bnOrZero } from '@/lib/bignumber/bignumber'
+import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils'
import { GradientApy } from '@/pages/Yields/components/GradientApy'
@@ -53,6 +56,7 @@ type YieldItemProps = {
onEnter?: (yieldItem: AugmentedYieldDto) => void
searchString?: string
titleOverride?: string
+ showAvailableOnly?: boolean
}
export const YieldItem = memo(
@@ -64,6 +68,7 @@ export const YieldItem = memo(
onEnter,
searchString,
titleOverride,
+ showAvailableOnly = false,
}: YieldItemProps) => {
const navigate = useNavigate()
const translate = useTranslate()
@@ -107,19 +112,13 @@ export const YieldItem = memo(
}
}, [data, isSingle, yieldProviders])
- const apyFormatted = useMemo(() => `${(stats.apy * 100).toFixed(2)}%`, [stats.apy])
+ const apyFormatted = `${(stats.apy * 100).toFixed(2)}%`
+ const tvlUserCurrency = bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed()
+ const userBalanceUserCurrency = userBalanceUsd
+ ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed()
+ : undefined
- const tvlUserCurrency = useMemo(
- () => bnOrZero(stats.tvlUsd).times(userCurrencyToUsdRate).toFixed(),
- [stats.tvlUsd, userCurrencyToUsdRate],
- )
-
- const userBalanceUserCurrency = useMemo(
- () => (userBalanceUsd ? userBalanceUsd.times(userCurrencyToUsdRate).toFixed() : undefined),
- [userBalanceUsd, userCurrencyToUsdRate],
- )
-
- const hasBalance = userBalanceUsd && userBalanceUsd.gt(0)
+ const hasBalance = userBalanceUsd && userBalanceUsd.gt(0) && !showAvailableOnly
const hasAvailable = availableBalanceUserCurrency && availableBalanceUserCurrency.gt(0)
const handleClick = useCallback(() => {
@@ -151,20 +150,40 @@ export const YieldItem = memo(
return
}, [data, isSingle, variant])
- const subtitle = useMemo(() => {
- if (isSingle) {
- return data.providerName ?? data.yieldItem.providerId
- }
- return `${stats.count} ${
- stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets')
- }`
- }, [data, isSingle, stats.count, translate])
+ const subtitle = isSingle
+ ? data.providerName ?? data.yieldItem.providerId
+ : `${stats.count} ${
+ stats.count === 1 ? translate('yieldXYZ.market') : translate('yieldXYZ.markets')
+ }`
+
+ const title =
+ titleOverride ?? (isSingle ? getYieldDisplayName(data.yieldItem) : data.assetSymbol)
+
+ const underMaintenance = isSingle ? data.yieldItem.metadata.underMaintenance : undefined
+ const deprecated = isSingle ? data.yieldItem.metadata.deprecated : undefined
- const title = useMemo(() => {
- if (titleOverride) return titleOverride
- if (isSingle) return data.yieldItem.metadata.name
- return data.assetSymbol
- }, [data, isSingle, titleOverride])
+ const statusBadge = useMemo(() => {
+ if (!isSingle) return null
+ if (deprecated) {
+ return (
+
+
+ {translate('yieldXYZ.deprecated')}
+
+
+ )
+ }
+ if (underMaintenance) {
+ return (
+
+
+ {translate('yieldXYZ.underMaintenance')}
+
+
+ )
+ }
+ return null
+ }, [isSingle, deprecated, underMaintenance, translate])
const showAvailable = isSingle && hasAvailable && !hasBalance
@@ -311,9 +330,12 @@ export const YieldItem = memo(
{iconElement}
-
- {title}
-
+
+
+ {title}
+
+ {statusBadge}
+
@@ -380,9 +402,12 @@ export const YieldItem = memo(
{iconElement}
-
- {title}
-
+
+
+ {title}
+
+ {statusBadge}
+
{subtitle}
@@ -475,6 +500,7 @@ export const YieldItem = memo(
+ {statusBadge}
diff --git a/src/pages/Yields/components/YieldManager.tsx b/src/pages/Yields/components/YieldManager.tsx
index cde4826198e..c6b5792c391 100644
--- a/src/pages/Yields/components/YieldManager.tsx
+++ b/src/pages/Yields/components/YieldManager.tsx
@@ -15,6 +15,7 @@ import {
SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID,
} from '@/lib/yieldxyz/constants'
import { YieldBalanceType } from '@/lib/yieldxyz/types'
+import { getYieldActionLabelKeys } from '@/lib/yieldxyz/utils'
import { useYieldAccount } from '@/pages/Yields/YieldAccountContext'
import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances'
import { useYield } from '@/react-queries/queries/yieldxyz/useYield'
@@ -49,24 +50,21 @@ export const YieldManager = () => {
const inputTokenSymbol = yieldItem?.inputTokens[0]?.symbol
const claimableTokenSymbol = balances?.byType[YieldBalanceType.Claimable]?.token?.symbol
- const isStaking = yieldItem?.mechanics.type === 'staking'
const title = useMemo(() => {
+ if (!yieldItem) return translate('yieldXYZ.manage')
+ const actionLabelKeys = getYieldActionLabelKeys(yieldItem.mechanics.type)
if (action === 'enter') {
- return isStaking
- ? translate('yieldXYZ.stakeSymbol', { symbol: inputTokenSymbol })
- : translate('yieldXYZ.depositSymbol', { symbol: inputTokenSymbol })
+ return `${translate(actionLabelKeys.enter)} ${inputTokenSymbol ?? ''}`
}
if (action === 'exit') {
- return isStaking
- ? translate('yieldXYZ.unstakeSymbol', { symbol: inputTokenSymbol })
- : translate('yieldXYZ.withdrawSymbol', { symbol: inputTokenSymbol })
+ return `${translate(actionLabelKeys.exit)} ${inputTokenSymbol ?? ''}`
}
if (action === 'claim') {
return translate('yieldXYZ.claimSymbol', { symbol: claimableTokenSymbol ?? '' })
}
return translate('yieldXYZ.manage')
- }, [action, isStaking, translate, inputTokenSymbol, claimableTokenSymbol])
+ }, [action, yieldItem, translate, inputTokenSymbol, claimableTokenSymbol])
if (!yieldItem) return null
diff --git a/src/pages/Yields/components/YieldOpportunityStats.tsx b/src/pages/Yields/components/YieldOpportunityStats.tsx
index 04d927d5c6c..672f76d2aad 100644
--- a/src/pages/Yields/components/YieldOpportunityStats.tsx
+++ b/src/pages/Yields/components/YieldOpportunityStats.tsx
@@ -31,8 +31,9 @@ type YieldOpportunityStatsProps = {
positions: AugmentedYieldDto[]
balances: Record | undefined
allYields: AugmentedYieldDto[] | undefined
- isMyOpportunities?: boolean
- onToggleMyOpportunities?: () => void
+ isAvailableToEarnTab?: boolean
+ onNavigateToAvailableTab?: () => void
+ onNavigateToAllTab?: () => void
isConnected: boolean
isMobile?: boolean
}
@@ -41,8 +42,9 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({
positions,
balances,
allYields,
- isMyOpportunities,
- onToggleMyOpportunities,
+ isAvailableToEarnTab,
+ onNavigateToAvailableTab,
+ onNavigateToAllTab,
isConnected,
isMobile,
}: YieldOpportunityStatsProps) {
@@ -51,16 +53,18 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({
const portfolioBalances = useAppSelector(selectPortfolioUserCurrencyBalances)
const { data: yields } = useYields()
- const activeValueUsd = useMemo(() => {
- return positions.reduce((acc, position) => {
- const positionBalances = balances?.[position.id]
- if (!positionBalances) return acc
- const activeBalances = positionBalances.filter(
- b => b.type === 'active' || b.type === 'locked',
- )
- return activeBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc)
- }, bnOrZero(0))
- }, [positions, balances])
+ const activeValueUsd = useMemo(
+ () =>
+ positions.reduce((acc, position) => {
+ const positionBalances = balances?.[position.id]
+ if (!positionBalances) return acc
+ const activeBalances = positionBalances.filter(
+ b => b.type === 'active' || b.type === 'locked',
+ )
+ return activeBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), acc)
+ }, bnOrZero(0)),
+ [positions, balances],
+ )
const idleValueUsd = useMemo(() => {
if (!isConnected || !allYields) return bnOrZero(0)
@@ -80,7 +84,6 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({
}, bnOrZero(0))
}, [isConnected, allYields, portfolioBalances])
- // Calculate weighted APY and potential earnings based on user's actual held assets
const { weightedApy, potentialEarningsValue } = useMemo(() => {
if (!isConnected || !yields?.byInputAssetId || !portfolioBalances) {
return { weightedApy: 0, potentialEarningsValue: bnOrZero(0) }
@@ -91,7 +94,7 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({
for (const [assetId, balanceFiat] of Object.entries(portfolioBalances)) {
const yieldsForAsset = yields.byInputAssetId[assetId]
- if (!yieldsForAsset?.length) continue // Early bail - no yield for this asset
+ if (!yieldsForAsset?.length) continue
const balance = bnOrZero(balanceFiat)
const bestApy = Math.max(...yieldsForAsset.map(y => y.rewardRate.total))
@@ -107,99 +110,57 @@ export const YieldOpportunityStats = memo(function YieldOpportunityStats({
return { weightedApy: avgApy, potentialEarningsValue: totalEarnings }
}, [isConnected, yields?.byInputAssetId, portfolioBalances])
- const hasActiveDeposits = useMemo(() => activeValueUsd.gt(0), [activeValueUsd])
-
- const activeValueFormatted = useMemo(
- () => activeValueUsd.times(userCurrencyToUsdRate).toFixed(),
- [activeValueUsd, userCurrencyToUsdRate],
- )
-
- const idleValueFormatted = useMemo(() => idleValueUsd.toFixed(), [idleValueUsd])
-
- const potentialEarnings = useMemo(
- () => potentialEarningsValue.toFixed(),
- [potentialEarningsValue],
- )
-
- const weightedApyFormatted = useMemo(() => weightedApy.toFixed(2), [weightedApy])
-
- const positionsCount = useMemo(() => positions.length, [positions.length])
-
- const gridColumn = useMemo(
- () => ({ md: hasActiveDeposits ? 'span 2' : 'span 3' }),
- [hasActiveDeposits],
- )
-
- const buttonBg = useMemo(
- () => (isMyOpportunities ? 'whiteAlpha.300' : 'blue.500'),
- [isMyOpportunities],
- )
-
- const buttonHoverBg = useMemo(
- () => ({ bg: isMyOpportunities ? 'whiteAlpha.400' : 'blue.400' }),
- [isMyOpportunities],
- )
+ const hasActiveDeposits = activeValueUsd.gt(0)
+ const activeValueFormatted = activeValueUsd.times(userCurrencyToUsdRate).toFixed()
+ const idleValueFormatted = idleValueUsd.toFixed()
+ const potentialEarnings = potentialEarningsValue.toFixed()
+ const weightedApyFormatted = weightedApy.toFixed(2)
+ const positionsCount = positions.length
+ const gridColumn = { md: hasActiveDeposits ? 'span 2' : 'span 3' }
+ const buttonBg = isAvailableToEarnTab ? 'whiteAlpha.300' : 'blue.500'
+ const buttonHoverBg = { bg: isAvailableToEarnTab ? 'whiteAlpha.400' : 'blue.400' }
+ const buttonText = isAvailableToEarnTab
+ ? translate('yieldXYZ.showAll')
+ : translate('yieldXYZ.earn')
- const buttonText = useMemo(
- () => (isMyOpportunities ? translate('yieldXYZ.showAll') : translate('yieldXYZ.earn')),
- [isMyOpportunities, translate],
- )
-
- const activeDepositsCard = useMemo(() => {
- if (!hasActiveDeposits) return null
- return (
-
-
- {chartPieIcon}
-
-
-
- {translate('yieldXYZ.activePositions')}
-
-
-
-
-
- {translate('yieldXYZ.acrossPositions', { count: positionsCount })}
-
-
-
- )
- }, [hasActiveDeposits, activeValueFormatted, positionsCount, translate])
-
- const toggleButton = useMemo(() => {
- if (!onToggleMyOpportunities) return null
- return (
-
- {buttonText}
-
- )
- }, [onToggleMyOpportunities, buttonBg, buttonHoverBg, buttonText])
+ const toggleButtonClickHandler = isAvailableToEarnTab
+ ? onNavigateToAllTab
+ : onNavigateToAvailableTab
if (isMobile) return null
return (
- {activeDepositsCard}
+ {hasActiveDeposits && (
+
+
+ {chartPieIcon}
+
+
+
+ {translate('yieldXYZ.activePositions')}
+
+
+
+
+
+ {translate('yieldXYZ.acrossPositions', { count: positionsCount })}
+
+
+
+ )}
{translate('yieldXYZ.perYear')}
- {toggleButton}
+ {toggleButtonClickHandler && (
+
+ {buttonText}
+
+ )}
diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx
index 143b6da39d5..e468aae7977 100644
--- a/src/pages/Yields/components/YieldPositionCard.tsx
+++ b/src/pages/Yields/components/YieldPositionCard.tsx
@@ -1,3 +1,4 @@
+import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons'
import {
Alert,
Badge,
@@ -8,6 +9,7 @@ import {
Divider,
Flex,
Heading,
+ HStack,
Skeleton,
Text,
VStack,
@@ -17,14 +19,15 @@ import dayjs from 'dayjs'
import qs from 'qs'
import { memo, useCallback, useMemo } from 'react'
import { useTranslate } from 'react-polyglot'
-import { useNavigate, useSearchParams } from 'react-router-dom'
+import { useNavigate } from 'react-router-dom'
import { Amount } from '@/components/Amount/Amount'
+import { Display } from '@/components/Display'
import { useBrowserRouter } from '@/hooks/useBrowserRouter/useBrowserRouter'
import { bnOrZero } from '@/lib/bignumber/bignumber'
-import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
import { YieldBalanceType } from '@/lib/yieldxyz/types'
+import { getYieldActionLabelKeys } from '@/lib/yieldxyz/utils'
import { useYieldAccount } from '@/pages/Yields/YieldAccountContext'
import type {
AggregatedBalance,
@@ -37,26 +40,37 @@ import {
} from '@/state/slices/selectors'
import { useAppSelector } from '@/state/store'
+const enterIcon =
+const exitIcon =
+
+const loadingState = (
+
+
+
+
+)
+
type YieldPositionCardProps = {
yieldItem: AugmentedYieldDto
balances: NormalizedYieldBalances | undefined
isBalancesLoading: boolean
+ selectedValidatorAddress: string | undefined
}
export const YieldPositionCard = memo(
- ({ yieldItem, balances, isBalancesLoading }: YieldPositionCardProps) => {
+ ({
+ yieldItem,
+ balances,
+ isBalancesLoading,
+ selectedValidatorAddress,
+ }: YieldPositionCardProps) => {
const translate = useTranslate()
const navigate = useNavigate()
const { location } = useBrowserRouter()
- const [searchParams] = useSearchParams()
- const validatorParam = searchParams.get('validator')
const { chainId } = yieldItem
const { accountId: contextAccountId, accountNumber } = useYieldAccount()
- const defaultValidator = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined
- const selectedValidatorAddress = validatorParam || defaultValidator
-
const accountId = useAppSelector(state => {
if (contextAccountId) return contextAccountId
if (!chainId) return undefined
@@ -103,10 +117,7 @@ export const YieldPositionCard = memo(
)
}, [])
- const hasEntering = useMemo(
- () => enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0),
- [enteringBalance],
- )
+ const hasEntering = Boolean(enteringBalance && bnOrZero(enteringBalance.aggregatedAmount).gt(0))
const exitingEntries = useMemo(() => {
if (!balances?.raw) return []
@@ -118,12 +129,13 @@ export const YieldPositionCard = memo(
})
}, [balances?.raw, selectedValidatorAddress])
- const hasExiting = useMemo(() => exitingEntries.length > 0, [exitingEntries])
- const hasWithdrawable = useMemo(
- () => withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0),
- [withdrawableBalance],
+ const hasExiting = exitingEntries.length > 0
+ const hasWithdrawable = Boolean(
+ withdrawableBalance && bnOrZero(withdrawableBalance.aggregatedAmount).gt(0),
+ )
+ const hasClaimable = Boolean(
+ claimableBalance && bnOrZero(claimableBalance.aggregatedAmount).gt(0),
)
- const hasClaimable = useMemo(() => Boolean(claimableBalance), [claimableBalance])
const totalValueUsd = useMemo(
() =>
@@ -148,7 +160,7 @@ export const YieldPositionCard = memo(
[activeBalance, enteringBalance, exitingBalance, withdrawableBalance],
)
- const hasAnyPosition = useMemo(() => totalAmount.gt(0), [totalAmount])
+ const hasAnyPosition = totalAmount.gt(0)
const { data: validators } = useYieldValidators(yieldItem.id)
@@ -162,46 +174,37 @@ export const YieldPositionCard = memo(
return foundInBalances?.validator?.name
}, [validators, selectedValidatorAddress, balances])
- const headingText = useMemo(
- () =>
- selectedValidatorName
- ? translate('yieldXYZ.myValidatorPosition', { validator: selectedValidatorName })
- : translate('yieldXYZ.myPosition'),
- [selectedValidatorName, translate],
- )
-
- const addressBadgeText = useMemo(
- () => (address ? `${address.slice(0, 4)}...${address.slice(-4)}` : ''),
- [address],
- )
+ const headingText = selectedValidatorName
+ ? translate('yieldXYZ.myValidatorPosition', { validator: selectedValidatorName })
+ : translate('yieldXYZ.myPosition')
- const totalAmountFixed = useMemo(() => totalAmount.toFixed(), [totalAmount])
+ const addressBadgeText = address ? `${address.slice(0, 4)}...${address.slice(-4)}` : ''
- const handleClaimClick = useCallback(() => {
- navigate({
- pathname: location.pathname,
- search: qs.stringify({
- action: 'claim',
- modal: 'yield',
- ...(selectedValidatorAddress ? { validator: selectedValidatorAddress } : {}),
- }),
- })
- }, [navigate, location.pathname, selectedValidatorAddress])
+ const totalAmountFixed = totalAmount.toFixed()
- const showPendingActions = useMemo(
- () => hasEntering || hasExiting || hasWithdrawable || hasClaimable,
- [hasEntering, hasExiting, hasWithdrawable, hasClaimable],
+ const navigateToAction = useCallback(
+ (action: 'claim' | 'enter' | 'exit') => {
+ navigate({
+ pathname: location.pathname,
+ search: qs.stringify({
+ action,
+ modal: 'yield',
+ ...(selectedValidatorAddress ? { validator: selectedValidatorAddress } : {}),
+ }),
+ })
+ },
+ [navigate, location.pathname, selectedValidatorAddress],
)
- const loadingState = useMemo(
- () => (
-
-
-
-
- ),
- [],
- )
+ const handleClaimClick = useCallback(() => navigateToAction('claim'), [navigateToAction])
+ const handleEnter = useCallback(() => navigateToAction('enter'), [navigateToAction])
+ const handleExit = useCallback(() => navigateToAction('exit'), [navigateToAction])
+
+ const actionLabelKeys = getYieldActionLabelKeys(yieldItem.mechanics.type)
+ const enterLabel = translate(actionLabelKeys.enter)
+ const exitLabel = translate(actionLabelKeys.exit)
+
+ const showPendingActions = hasEntering || hasExiting || hasWithdrawable || hasClaimable
const enteringSection = useMemo(() => {
if (!hasEntering) return null
@@ -380,8 +383,6 @@ export const YieldPositionCard = memo(
)
}
- if (!hasAnyPosition && !showPendingActions) return null
-
return (
@@ -414,6 +415,36 @@ export const YieldPositionCard = memo(
{pendingActionsSection}
+
+
+
+ {enterLabel}
+
+ {hasAnyPosition && (
+
+ {exitLabel}
+
+ )}
+
+
diff --git a/src/pages/Yields/components/YieldProviderInfo.tsx b/src/pages/Yields/components/YieldProviderInfo.tsx
new file mode 100644
index 00000000000..e3a0a09b9f8
--- /dev/null
+++ b/src/pages/Yields/components/YieldProviderInfo.tsx
@@ -0,0 +1,85 @@
+import { ExternalLinkIcon } from '@chakra-ui/icons'
+import { Avatar, Box, Button, Flex, Heading, HStack, Link, Text } from '@chakra-ui/react'
+import { memo, useMemo } from 'react'
+import { useTranslate } from 'react-polyglot'
+
+import { Display } from '@/components/Display'
+
+type YieldProviderInfoProps = {
+ providerId: string
+ providerName: string
+ providerLogoURI?: string
+ providerWebsite?: string
+}
+
+export const YieldProviderInfo = memo(
+ ({ providerId, providerName, providerLogoURI, providerWebsite }: YieldProviderInfoProps) => {
+ const translate = useTranslate()
+
+ const descriptionKey = `yieldXYZ.providerDescriptions.${providerId}`
+ const description = useMemo(() => {
+ const translated = translate(descriptionKey)
+ return translated !== descriptionKey ? translated : null
+ }, [translate, descriptionKey])
+
+ if (!description) return null
+
+ return (
+
+
+
+
+
+
+ {providerLogoURI && (
+
+ )}
+
+ {translate('yieldXYZ.aboutProvider', { provider: providerName })}
+
+
+
+ {description}
+
+ {providerWebsite && (
+
+ }
+ color='text.subtle'
+ _hover={{ color: 'text.base' }}
+ >
+ {translate('yieldXYZ.visitWebsite')}
+
+
+ )}
+
+
+
+
+
+
+
+
+ {providerLogoURI && }
+
+ {translate('yieldXYZ.aboutProvider', { provider: providerName })}
+
+
+
+ {description}
+
+ {providerWebsite && (
+
+ }>
+ {translate('yieldXYZ.visitWebsite')}
+
+
+ )}
+
+
+
+ )
+ },
+)
diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx
new file mode 100644
index 00000000000..a4fa1da5b94
--- /dev/null
+++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx
@@ -0,0 +1,79 @@
+import { Box, Heading, SimpleGrid, useMediaQuery } from '@chakra-ui/react'
+import { memo, useCallback, useMemo } from 'react'
+import { useTranslate } from 'react-polyglot'
+import { useNavigate } from 'react-router-dom'
+
+import { YieldItem } from '@/pages/Yields/components/YieldItem'
+import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders'
+import { useYields } from '@/react-queries/queries/yieldxyz/useYields'
+
+type YieldRelatedMarketsProps = {
+ currentYieldId: string
+ tokenSymbol: string
+}
+
+export const YieldRelatedMarkets = memo(
+ ({ currentYieldId, tokenSymbol }: YieldRelatedMarketsProps) => {
+ const translate = useTranslate()
+ const navigate = useNavigate()
+ const [isMobile] = useMediaQuery('(max-width: 768px)')
+ const { data: yields } = useYields()
+ const { data: yieldProviders } = useYieldProviders()
+
+ const relatedYields = useMemo(() => {
+ if (!yields?.all) return []
+
+ return yields.all
+ .filter(y => {
+ if (y.id === currentYieldId) return false
+ const inputSymbol = y.inputTokens?.[0]?.symbol || y.token.symbol
+ return inputSymbol === tokenSymbol
+ })
+ .sort((a, b) => b.rewardRate.total - a.rewardRate.total)
+ .slice(0, 6)
+ }, [yields?.all, currentYieldId, tokenSymbol])
+
+ const handleYieldClick = useCallback(
+ (yieldId: string) => {
+ navigate(`/yields/${yieldId}`)
+ },
+ [navigate],
+ )
+
+ const getProviderInfo = useCallback(
+ (providerId: string) => {
+ const provider = yieldProviders?.[providerId]
+ return { name: provider?.name, logo: provider?.logoURI }
+ },
+ [yieldProviders],
+ )
+
+ if (relatedYields.length === 0) return null
+
+ return (
+
+
+ {translate('yieldXYZ.otherYields', { symbol: tokenSymbol })}
+
+
+ {relatedYields.map(y => {
+ const providerInfo = getProviderInfo(y.providerId)
+ return (
+ handleYieldClick(y.id)}
+ />
+ )
+ })}
+
+
+ )
+ },
+)
diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx
index 981e3ca7375..ff6693763b5 100644
--- a/src/pages/Yields/components/YieldStats.tsx
+++ b/src/pages/Yields/components/YieldStats.tsx
@@ -11,6 +11,7 @@ import {
SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID,
} from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto } from '@/lib/yieldxyz/types'
+import { isStakingYieldType } from '@/lib/yieldxyz/utils'
import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances'
import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators'
import {
@@ -33,22 +34,21 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => {
)
const [searchParams] = useSearchParams()
- const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams])
+ const validatorParam = searchParams.get('validator')
- const shouldFetchValidators = useMemo(
- () => yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection,
- [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection],
- )
+ const isStaking = isStakingYieldType(yieldItem.mechanics.type)
+ const shouldFetchValidators = isStaking && yieldItem.mechanics.requiresValidatorSelection
const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators)
- const defaultValidator = useMemo(() => {
- if (yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId])
- return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]
- return validators?.[0]?.address
- }, [yieldItem.chainId, validators])
+ const defaultValidator = useMemo(
+ () =>
+ yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]
+ ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId]
+ : validators?.[0]?.address,
+ [yieldItem.chainId, validators],
+ )
const selectedValidatorAddress = useMemo(() => {
- // For native staking with hardcoded defaults, always use the default validator (ignore URL param)
if (
yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID ||
yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID ||
@@ -67,7 +67,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => {
?.validator
if (inBalances) return inBalances
return undefined
- }, [validators, selectedValidatorAddress, balances])
+ }, [selectedValidatorAddress, validators, balances?.raw])
const tvl = useMemo(() => {
const validatorTvl =
@@ -75,21 +75,23 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => {
return bnOrZero(yieldItem.statistics?.tvl ?? validatorTvl).toNumber()
}, [selectedValidator, yieldItem.statistics?.tvl])
- const tvlUserCurrency = useMemo(() => {
- if (yieldItem.statistics?.tvlUsd) {
- return bnOrZero(yieldItem.statistics.tvlUsd).times(userCurrencyToUsdRate).toFixed()
- }
- return bnOrZero(tvl)
- .times(bnOrZero(inputTokenMarketData?.price))
- .toFixed()
- }, [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price])
+ const tvlUserCurrency = useMemo(
+ () =>
+ yieldItem.statistics?.tvlUsd
+ ? bnOrZero(yieldItem.statistics.tvlUsd).times(userCurrencyToUsdRate).toFixed()
+ : bnOrZero(tvl)
+ .times(bnOrZero(inputTokenMarketData?.price))
+ .toFixed(),
+ [yieldItem.statistics?.tvlUsd, userCurrencyToUsdRate, tvl, inputTokenMarketData?.price],
+ )
- const validatorMetadata = useMemo(() => {
- if (yieldItem.mechanics.type !== 'staking') return null
- if (selectedValidator)
- return { name: selectedValidator.name, logoURI: selectedValidator.logoURI }
- return null
- }, [yieldItem.mechanics.type, selectedValidator])
+ const validatorMetadata = useMemo(
+ () =>
+ isStaking && selectedValidator
+ ? { name: selectedValidator.name, logoURI: selectedValidator.logoURI }
+ : null,
+ [isStaking, selectedValidator],
+ )
return (
diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx
index bda30387abe..16f5a947c20 100644
--- a/src/pages/Yields/components/YieldsList.tsx
+++ b/src/pages/Yields/components/YieldsList.tsx
@@ -45,7 +45,7 @@ import {
YIELD_NETWORK_TO_CHAIN_ID,
} from '@/lib/yieldxyz/constants'
import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types'
-import { resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils'
+import { isStakingYieldType, resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils'
import { YieldFilters } from '@/pages/Yields/components/YieldFilters'
import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldItem'
import { YieldOpportunityStats } from '@/pages/Yields/components/YieldOpportunityStats'
@@ -66,23 +66,28 @@ import { useAppSelector } from '@/state/store'
const tabSelectedSx = { color: 'white', bg: 'blue.500' }
+const TAB_PARAMS = ['all', 'available', 'my-positions'] as const
+type YieldTab = (typeof TAB_PARAMS)[number]
+
export const YieldsList = memo(() => {
const translate = useTranslate()
const navigate = useNavigate()
const { state: walletState } = useWallet()
- const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo])
+ const isConnected = Boolean(walletState.walletInfo)
const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds)
const [isMobile] = useMediaQuery('(max-width: 768px)')
const [searchParams, setSearchParams] = useSearchParams()
- const tabParam = useMemo(() => searchParams.get('tab'), [searchParams])
- const tabIndex = useMemo(() => (tabParam === 'my-positions' ? 1 : 0), [tabParam])
- const filterOption = useMemo(() => searchParams.get('filter'), [searchParams])
- const isMyOpportunities = useMemo(() => filterOption === 'my-assets', [filterOption])
- const viewParam = useMemo(() => searchParams.get('view'), [searchParams])
- const viewMode = useMemo<'grid' | 'list'>(
- () => (viewParam === 'list' ? 'list' : 'grid'),
- [viewParam],
- )
+ const tabParam = searchParams.get('tab') as YieldTab | null
+ const tabIndex = useMemo(() => {
+ if (tabParam) {
+ const idx = TAB_PARAMS.indexOf(tabParam)
+ return idx >= 0 ? idx : 0
+ }
+ return isConnected ? 1 : 0
+ }, [tabParam, isConnected])
+ const isAvailableToEarnTab = tabParam === 'available' || (!tabParam && isConnected)
+ const viewParam = searchParams.get('view')
+ const viewMode: 'grid' | 'list' = viewParam === 'list' ? 'list' : 'grid'
const setViewMode = useCallback(
(mode: 'grid' | 'list') => {
setSearchParams(prev => {
@@ -95,7 +100,7 @@ export const YieldsList = memo(() => {
[setSearchParams],
)
const [searchQuery, setSearchQuery] = useState('')
- const filterSearchString = useMemo(() => searchParams.toString(), [searchParams])
+ const filterSearchString = searchParams.toString()
const {
selectedNetwork,
@@ -108,7 +113,7 @@ export const YieldsList = memo(() => {
handleProviderChange,
handleTypeChange,
handleSortChange,
- } = useYieldFilters()
+ } = useYieldFilters(isAvailableToEarnTab)
const userCurrencyBalances = useAppSelector(selectPortfolioUserCurrencyBalances)
const assetBalancesBaseUnit = useAppSelector(selectPortfolioAssetBalancesBaseUnit)
@@ -134,22 +139,28 @@ export const YieldsList = memo(() => {
(index: number) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
- if (index === 0) next.delete('tab')
- else next.set('tab', 'my-positions')
+ next.set('tab', TAB_PARAMS[index])
return next
})
},
[setSearchParams],
)
- const handleToggleMyOpportunities = useCallback(() => {
+ const handleNavigateToAvailableTab = useCallback(() => {
+ setSearchParams(prev => {
+ const next = new URLSearchParams(prev)
+ next.set('tab', 'available')
+ return next
+ })
+ }, [setSearchParams])
+
+ const handleNavigateToAllTab = useCallback(() => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
- if (isMyOpportunities) next.delete('filter')
- else next.set('filter', 'my-assets')
+ next.set('tab', 'all')
return next
})
- }, [isMyOpportunities, setSearchParams])
+ }, [setSearchParams])
const getProviderLogo = useCallback(
(providerId: string) => yieldProviders?.[providerId]?.logoURI,
@@ -159,7 +170,8 @@ export const YieldsList = memo(() => {
const getYieldDisplayInfo = useCallback(
(yieldItem: AugmentedYieldDto) => {
const isNativeStaking =
- yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection
+ isStakingYieldType(yieldItem.mechanics.type) &&
+ yieldItem.mechanics.requiresValidatorSelection
if (yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) {
return {
@@ -225,52 +237,86 @@ export const YieldsList = memo(() => {
[],
)
- const networks = useMemo(
- () =>
- yields?.meta?.networks
- ? yields.meta.networks.map(net => ({
- id: net,
- name: net.charAt(0).toUpperCase() + net.slice(1),
- chainId: YIELD_NETWORK_TO_CHAIN_ID[net as YieldNetwork],
- }))
- : [],
- [yields],
- )
+ const unfilteredAvailableYields = useMemo(() => {
+ if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit)
+ return []
+
+ const available: AugmentedYieldDto[] = []
+
+ for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) {
+ const yieldsForAsset = yields.byInputAssetId[assetId]
+ if (!yieldsForAsset?.length) continue
+
+ const balance = bnOrZero(balanceFiat)
+ if (balance.lte(0)) continue
+
+ const eligibleYields = yieldsForAsset.filter(y => {
+ const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum)
+ if (minDeposit.gt(0)) {
+ const asset = assets[assetId]
+ if (!asset) return false
+ const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId])
+ const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, asset.precision))
+ if (balanceHuman.lt(minDeposit)) return false
+ }
+ return true
+ })
+
+ available.push(...eligibleYields)
+ }
- const providers = useMemo(
+ return available
+ }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets])
+
+ const filterSourceYields = useMemo(
() =>
- yields?.meta?.providers
- ? yields.meta.providers.map(pId => ({
- id: pId,
- name: pId.charAt(0).toUpperCase() + pId.slice(1),
- icon: getProviderLogo(pId),
- }))
- : [],
- [yields, getProviderLogo],
+ isAvailableToEarnTab && unfilteredAvailableYields.length > 0
+ ? unfilteredAvailableYields
+ : undefined,
+ [isAvailableToEarnTab, unfilteredAvailableYields],
)
+ const networks = useMemo(() => {
+ const sourceNetworks = filterSourceYields
+ ? [...new Set(filterSourceYields.map(y => y.network))]
+ : yields?.meta?.networks ?? []
+
+ return sourceNetworks.map(net => ({
+ id: net,
+ name: net.charAt(0).toUpperCase() + net.slice(1),
+ chainId: YIELD_NETWORK_TO_CHAIN_ID[net as YieldNetwork],
+ }))
+ }, [filterSourceYields, yields?.meta?.networks])
+
+ const providers = useMemo(() => {
+ const sourceProviders = filterSourceYields
+ ? [...new Set(filterSourceYields.map(y => y.providerId))]
+ : yields?.meta?.providers ?? []
+
+ return sourceProviders.map(pId => ({
+ id: pId,
+ name: pId.charAt(0).toUpperCase() + pId.slice(1),
+ icon: getProviderLogo(pId),
+ }))
+ }, [filterSourceYields, yields?.meta?.providers, getProviderLogo])
+
const types = useMemo(() => {
- if (!yields?.all) return []
- const uniqueTypes = [...new Set(yields.all.map(y => y.mechanics.type))]
+ const sourceYields = filterSourceYields ?? yields?.all
+ if (!sourceYields) return []
+
+ const uniqueTypes = [...new Set(sourceYields.map(y => y.mechanics.type))]
return uniqueTypes.map(type => ({
id: type,
name: type.charAt(0).toUpperCase() + type.slice(1).replace(/-/g, ' '),
}))
- }, [yields])
+ }, [filterSourceYields, yields?.all])
const yieldsByAsset = useMemo(() => {
if (!yields?.assetGroups) return []
- const hasUserBalance = (y: AugmentedYieldDto) => {
- if (y.inputTokens?.some(t => bnOrZero(userCurrencyBalances[t.assetId || '']).gt(0)))
- return true
- return bnOrZero(userCurrencyBalances[y.token.assetId || '']).gt(0)
- }
-
return yields.assetGroups
.map(group => {
let filteredYields = group.yields
- if (isMyOpportunities) filteredYields = filteredYields.filter(hasUserBalance)
if (selectedNetwork)
filteredYields = filteredYields.filter(y => y.network === selectedNetwork)
if (selectedProvider)
@@ -332,19 +378,28 @@ export const YieldsList = memo(() => {
}[]
}, [
yields?.assetGroups,
- isMyOpportunities,
selectedNetwork,
selectedProvider,
selectedType,
searchQuery,
getYieldPositionBalanceUsd,
sortOption,
- userCurrencyBalances,
])
const recommendedYields = useMemo(() => {
- if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit)
- return []
+ if (!unfilteredAvailableYields.length || !userCurrencyBalances) return []
+
+ const yieldsByAssetId = unfilteredAvailableYields.reduce>(
+ (acc, item) => {
+ const assetId = item.inputTokens?.[0]?.assetId
+ if (assetId) {
+ if (!acc[assetId]) acc[assetId] = []
+ acc[assetId].push(item)
+ }
+ return acc
+ },
+ {},
+ )
const recommendations: {
yield: AugmentedYieldDto
@@ -352,29 +407,11 @@ export const YieldsList = memo(() => {
potentialEarnings: ReturnType
}[] = []
- for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) {
- const yieldsForAsset = yields.byInputAssetId[assetId]
- if (!yieldsForAsset?.length) continue
-
- const balance = bnOrZero(balanceFiat)
+ for (const [assetId, yieldsForAsset] of Object.entries(yieldsByAssetId)) {
+ const balance = bnOrZero(userCurrencyBalances[assetId])
if (balance.lte(0)) continue
- // Filter to only include yields where user meets the minimum requirement
- const eligibleYields = yieldsForAsset.filter(y => {
- const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum)
- if (minDeposit.lte(0)) return true
-
- // minDeposit is human readable (e.g. 32), so convert base balance to human
- const asset = assets[assetId]
- if (!asset) return false
- const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId])
- const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, asset.precision))
- return balanceHuman.gte(minDeposit)
- })
-
- if (!eligibleYields.length) continue
-
- const bestYield = eligibleYields.reduce((best, current) =>
+ const bestYield = yieldsForAsset.reduce((best, current) =>
current.rewardRate.total > best.rewardRate.total ? current : best,
)
@@ -388,71 +425,84 @@ export const YieldsList = memo(() => {
return recommendations
.sort((a, b) => b.potentialEarnings.minus(a.potentialEarnings).toNumber())
.slice(0, 3)
- }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets])
+ }, [unfilteredAvailableYields, userCurrencyBalances])
const availableYields = useMemo(() => {
- if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit)
- return []
+ if (!unfilteredAvailableYields.length || !userCurrencyBalances) return []
const available: {
yield: AugmentedYieldDto
balanceFiat: ReturnType
}[] = []
- for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) {
- const yieldsForAsset = yields.byInputAssetId[assetId]
- if (!yieldsForAsset?.length) continue
+ for (const yieldItem of unfilteredAvailableYields) {
+ const inputAssetId = yieldItem.inputTokens?.[0]?.assetId
+ if (!inputAssetId) continue
- const balance = bnOrZero(balanceFiat)
- if (balance.lte(0)) continue
+ const balanceFiat = bnOrZero(userCurrencyBalances[inputAssetId])
- const eligibleYields = yieldsForAsset.filter(y => {
- const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum)
- if (minDeposit.gt(0)) {
- const asset = assets[assetId]
- if (!asset) return false
- const baseBalance = bnOrZero(assetBalancesBaseUnit[assetId])
- const balanceHuman = bnOrZero(fromBaseUnit(baseBalance, asset.precision))
- if (balanceHuman.lt(minDeposit)) return false
- }
- if (selectedNetwork && y.network !== selectedNetwork) return false
- if (selectedProvider && y.providerId !== selectedProvider) return false
- if (selectedType && y.mechanics.type !== selectedType) return false
- if (searchQuery && !searchYields([y], searchQuery).length) return false
- return true
- })
+ if (selectedNetwork && yieldItem.network !== selectedNetwork) continue
+ if (selectedProvider && yieldItem.providerId !== selectedProvider) continue
+ if (selectedType && yieldItem.mechanics.type !== selectedType) continue
+ if (searchQuery && !searchYields([yieldItem], searchQuery).length) continue
- for (const yieldItem of eligibleYields) {
- available.push({ yield: yieldItem, balanceFiat: balance })
- }
+ available.push({ yield: yieldItem, balanceFiat })
}
- return available.sort((a, b) =>
- bnOrZero(b.yield.rewardRate.total).minus(a.yield.rewardRate.total).toNumber(),
- )
+ return available.sort((a, b) => {
+ switch (sortOption) {
+ case 'yearly-return-desc': {
+ const aYearlyReturn = bnOrZero(a.yield.rewardRate.total).times(a.balanceFiat)
+ const bYearlyReturn = bnOrZero(b.yield.rewardRate.total).times(b.balanceFiat)
+ const diff = bYearlyReturn.minus(aYearlyReturn).toNumber()
+ if (diff !== 0) return diff
+ return bnOrZero(b.yield.rewardRate.total).minus(a.yield.rewardRate.total).toNumber()
+ }
+ case 'apy-desc':
+ return bnOrZero(b.yield.rewardRate.total).minus(a.yield.rewardRate.total).toNumber()
+ case 'apy-asc':
+ return bnOrZero(a.yield.rewardRate.total).minus(b.yield.rewardRate.total).toNumber()
+ case 'tvl-desc':
+ return bnOrZero(b.yield.statistics?.tvlUsd)
+ .minus(a.yield.statistics?.tvlUsd ?? 0)
+ .toNumber()
+ case 'tvl-asc':
+ return bnOrZero(a.yield.statistics?.tvlUsd)
+ .minus(b.yield.statistics?.tvlUsd ?? 0)
+ .toNumber()
+ case 'name-asc':
+ return a.yield.token.symbol.localeCompare(b.yield.token.symbol)
+ case 'name-desc':
+ return b.yield.token.symbol.localeCompare(a.yield.token.symbol)
+ default: {
+ const aYearlyReturnDefault = bnOrZero(a.yield.rewardRate.total).times(a.balanceFiat)
+ const bYearlyReturnDefault = bnOrZero(b.yield.rewardRate.total).times(b.balanceFiat)
+ return bYearlyReturnDefault.minus(aYearlyReturnDefault).toNumber()
+ }
+ }
+ })
}, [
- isConnected,
- yields?.byInputAssetId,
+ unfilteredAvailableYields,
userCurrencyBalances,
- assetBalancesBaseUnit,
- assets,
selectedNetwork,
selectedProvider,
selectedType,
searchQuery,
+ sortOption,
])
const myPositions = useMemo(() => {
- if (!yields?.all || !allBalances) return []
- const positions = yields.all.filter(yieldItem => {
+ if (!yields?.unfiltered || !allBalances) return []
+ const positions = yields.unfiltered.filter(yieldItem => {
const balances = allBalances[yieldItem.id]
if (!balances) return false
return balances.some(b => bnOrZero(b.amount).gt(0))
})
- return positions.filter(y => {
+ const filtered = positions.filter(y => {
if (selectedNetwork && y.network !== selectedNetwork) return false
if (selectedProvider && y.providerId !== selectedProvider) return false
+ if (selectedType && y.mechanics.type !== selectedType) return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
if (
@@ -465,7 +515,24 @@ export const YieldsList = memo(() => {
}
return true
})
- }, [yields, allBalances, selectedNetwork, selectedProvider, searchQuery])
+
+ return filtered.sort((a, b) => {
+ const aBalance =
+ allBalances[a.id]?.reduce((sum, bal) => sum.plus(bnOrZero(bal.amountUsd)), bnOrZero(0)) ??
+ bnOrZero(0)
+ const bBalance =
+ allBalances[b.id]?.reduce((sum, bal) => sum.plus(bnOrZero(bal.amountUsd)), bnOrZero(0)) ??
+ bnOrZero(0)
+ return bBalance.minus(aBalance).toNumber()
+ })
+ }, [
+ yields?.unfiltered,
+ allBalances,
+ selectedNetwork,
+ selectedProvider,
+ selectedType,
+ searchQuery,
+ ])
const handleYieldClick = useCallback(
(yieldId: string) => {
@@ -670,36 +737,8 @@ export const YieldsList = memo(() => {
[translate],
)
- const allYieldsGridElement = useMemo(() => {
- if (isMyOpportunities) {
- return (
-
- {availableYields.map(item => {
- const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id)
- const displayInfo = getYieldDisplayInfo(item.yield)
- const inputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol
- return (
- handleYieldClick(item.yield.id)}
- />
- )
- })}
-
- )
- }
-
- return (
+ const allYieldsGridElement = useMemo(
+ () => (
{yieldsByAsset.map(group => {
if (group.yields.length === 1) {
@@ -721,7 +760,6 @@ export const YieldsList = memo(() => {
variant={isMobile ? 'mobile' : 'card'}
userBalanceUsd={group.userGroupBalanceUsd}
availableBalanceUserCurrency={availableUsd}
- titleOverride={group.assetSymbol}
onEnter={() => handleYieldClick(singleYield.id)}
/>
)
@@ -745,21 +783,47 @@ export const YieldsList = memo(() => {
)
})}
- )
- }, [
- isMyOpportunities,
- availableYields,
- getYieldPositionBalanceUsd,
- filterSearchString,
- yieldsByAsset,
- isMobile,
- getYieldDisplayInfo,
- handleYieldClick,
- userCurrencyBalances,
- ])
+ ),
+ [
+ filterSearchString,
+ yieldsByAsset,
+ isMobile,
+ getYieldDisplayInfo,
+ handleYieldClick,
+ userCurrencyBalances,
+ ],
+ )
+
+ const availableToEarnGridElement = useMemo(
+ () => (
+
+ {availableYields.map(item => {
+ const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id)
+ const displayInfo = getYieldDisplayInfo(item.yield)
+ return (
+ handleYieldClick(item.yield.id)}
+ showAvailableOnly
+ />
+ )
+ })}
+
+ ),
+ [availableYields, getYieldPositionBalanceUsd, isMobile, getYieldDisplayInfo, handleYieldClick],
+ )
- const allYieldsListElement = useMemo(() => {
- const listHeader = (
+ const listHeader = useMemo(
+ () => (
{
- )
-
- if (isMyOpportunities) {
- return (
-
- {listHeader}
- {availableYields.map(item => {
- const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id)
- const rowDisplayInfo = getYieldDisplayInfo(item.yield)
- const rowInputSymbol = item.yield.inputTokens?.[0]?.symbol ?? item.yield.token.symbol
- return (
- handleYieldClick(item.yield.id)}
- />
- )
- })}
-
- )
- }
+ ),
+ [translate],
+ )
- return (
+ const allYieldsListElement = useMemo(
+ () => (
{listHeader}
{yieldsByAsset.map(group => {
@@ -850,7 +888,6 @@ export const YieldsList = memo(() => {
variant='row'
userBalanceUsd={group.userGroupBalanceUsd}
availableBalanceUserCurrency={availableUsd}
- titleOverride={group.assetSymbol}
onEnter={() => handleYieldClick(singleYield.id)}
/>
)
@@ -874,21 +911,54 @@ export const YieldsList = memo(() => {
)
})}
- )
- }, [
- isMyOpportunities,
- availableYields,
- getYieldPositionBalanceUsd,
- filterSearchString,
- translate,
- yieldsByAsset,
- getYieldDisplayInfo,
- handleYieldClick,
- userCurrencyBalances,
- ])
+ ),
+ [
+ listHeader,
+ filterSearchString,
+ yieldsByAsset,
+ getYieldDisplayInfo,
+ handleYieldClick,
+ userCurrencyBalances,
+ ],
+ )
+
+ const availableToEarnListElement = useMemo(
+ () => (
+
+ {listHeader}
+ {availableYields.map(item => {
+ const positionBalanceUsd = getYieldPositionBalanceUsd(item.yield.id)
+ const rowDisplayInfo = getYieldDisplayInfo(item.yield)
+ return (
+ handleYieldClick(item.yield.id)}
+ showAvailableOnly
+ />
+ )
+ })}
+
+ ),
+ [
+ listHeader,
+ availableYields,
+ getYieldPositionBalanceUsd,
+ getYieldDisplayInfo,
+ handleYieldClick,
+ ],
+ )
const recommendedStripElement = useMemo(() => {
- if (!isConnected || recommendedYields.length === 0 || isMyOpportunities) return null
+ if (!isConnected || recommendedYields.length === 0) return null
return (
@@ -898,7 +968,6 @@ export const YieldsList = memo(() => {
{recommendedYields.map(rec => {
const recDisplayInfo = getYieldDisplayInfo(rec.yield)
- const inputSymbol = rec.yield.inputTokens?.[0]?.symbol ?? rec.yield.token.symbol
return (
{
}}
variant={isMobile ? 'mobile' : 'card'}
availableBalanceUserCurrency={rec.balanceFiat}
- titleOverride={inputSymbol}
onEnter={() => handleYieldClick(rec.yield.id)}
/>
)
@@ -918,23 +986,14 @@ export const YieldsList = memo(() => {
)
- }, [
- isConnected,
- recommendedYields,
- isMyOpportunities,
- translate,
- getYieldDisplayInfo,
- handleYieldClick,
- isMobile,
- ])
+ }, [isConnected, recommendedYields, translate, getYieldDisplayInfo, handleYieldClick, isMobile])
const allYieldsContentElement = useMemo(() => {
if (isLoading)
return viewMode === 'grid' || isMobile
? allYieldsLoadingGridElement
: allYieldsLoadingListElement
- const isEmpty = isMyOpportunities ? availableYields.length === 0 : yieldsByAsset.length === 0
- if (isEmpty) return allYieldsEmptyElement
+ if (yieldsByAsset.length === 0) return allYieldsEmptyElement
return viewMode === 'grid' || isMobile ? allYieldsGridElement : allYieldsListElement
}, [
allYieldsEmptyElement,
@@ -943,13 +1002,47 @@ export const YieldsList = memo(() => {
allYieldsLoadingGridElement,
allYieldsLoadingListElement,
isLoading,
- isMyOpportunities,
- availableYields.length,
viewMode,
yieldsByAsset.length,
isMobile,
])
+ const availableToEarnEmptyElement = useMemo(
+ () => (
+
+ {translate('yieldXYZ.noAvailableYields')}
+
+ ),
+ [translate],
+ )
+
+ const availableToEarnContentElement = useMemo(() => {
+ if (!isConnected)
+ return (
+
+ )
+ if (isLoading)
+ return viewMode === 'grid' || isMobile
+ ? allYieldsLoadingGridElement
+ : allYieldsLoadingListElement
+ if (availableYields.length === 0) return availableToEarnEmptyElement
+ return viewMode === 'grid' || isMobile ? availableToEarnGridElement : availableToEarnListElement
+ }, [
+ isConnected,
+ isLoading,
+ viewMode,
+ isMobile,
+ allYieldsLoadingGridElement,
+ allYieldsLoadingListElement,
+ availableYields.length,
+ availableToEarnEmptyElement,
+ availableToEarnGridElement,
+ availableToEarnListElement,
+ ])
+
const positionsLoadingElement = useMemo(
() => (
@@ -978,22 +1071,22 @@ export const YieldsList = memo(() => {
const positionsGridElement = useMemo(
() => (
- {positionsTable.getRowModel().rows.map(row => {
- const posDisplayInfo = getYieldDisplayInfo(row.original)
+ {myPositions.map(position => {
+ const posDisplayInfo = getYieldDisplayInfo(position)
return (
handleYieldClick(row.original.id)}
+ onEnter={() => handleYieldClick(position.id)}
userBalanceUsd={
- allBalances?.[row.original.id]
- ? allBalances[row.original.id].reduce(
+ allBalances?.[position.id]
+ ? allBalances[position.id].reduce(
(sum, b) => sum.plus(bnOrZero(b.amountUsd)),
bnOrZero(0),
)
@@ -1004,21 +1097,23 @@ export const YieldsList = memo(() => {
})}
),
- [allBalances, getYieldDisplayInfo, handleYieldClick, positionsTable, isMobile],
+ [allBalances, getYieldDisplayInfo, handleYieldClick, myPositions, isMobile],
)
const positionsListElement = useMemo(
() => (
`${s.id}-${s.desc}`).join(',')}
+ key={`${positionsSorting.map(s => `${s.id}-${s.desc}`).join(',')}-${
+ myPositions.length
+ }-${myPositions.map(p => p.id).join(',')}`}
table={positionsTable}
isLoading={false}
onRowClick={handleRowClick}
/>
),
- [handleRowClick, positionsSorting, positionsTable],
+ [handleRowClick, positionsSorting, positionsTable, myPositions],
)
const positionsContentElement = useMemo(() => {
@@ -1069,8 +1164,9 @@ export const YieldsList = memo(() => {
positions={myPositions}
balances={allBalances}
allYields={yields?.all}
- isMyOpportunities={isMyOpportunities}
- onToggleMyOpportunities={handleToggleMyOpportunities}
+ isAvailableToEarnTab={isAvailableToEarnTab}
+ onNavigateToAvailableTab={handleNavigateToAvailableTab}
+ onNavigateToAllTab={handleNavigateToAllTab}
isConnected={isConnected}
isMobile={isMobile}
/>
@@ -1085,6 +1181,7 @@ export const YieldsList = memo(() => {
>
{translate('common.all')}
+ {translate('yieldXYZ.availableToEarn')}
{translate('yieldXYZ.myPositions')} ({myPositions.length})
@@ -1136,6 +1233,7 @@ export const YieldsList = memo(() => {
{allYieldsContentElement}
+ {availableToEarnContentElement}
{positionsContentElement}
diff --git a/src/pages/Yields/hooks/useYieldFilters.ts b/src/pages/Yields/hooks/useYieldFilters.ts
index 2d9354d1140..38629f6e282 100644
--- a/src/pages/Yields/hooks/useYieldFilters.ts
+++ b/src/pages/Yields/hooks/useYieldFilters.ts
@@ -6,6 +6,8 @@ import type { SortOption } from '@/pages/Yields/components/YieldFilters'
const getSortingFromOption = (sortOption: SortOption): SortingState => {
switch (sortOption) {
+ case 'yearly-return-desc':
+ return [{ id: 'yearly-return', desc: true }]
case 'apy-desc':
return [{ id: 'apy', desc: true }]
case 'apy-asc':
@@ -23,15 +25,16 @@ const getSortingFromOption = (sortOption: SortOption): SortingState => {
}
}
-export const useYieldFilters = () => {
+export const useYieldFilters = (isAvailableToEarnTab = false) => {
const [searchParams, setSearchParams] = useSearchParams()
const selectedNetwork = useMemo(() => searchParams.get('network'), [searchParams])
const selectedProvider = useMemo(() => searchParams.get('provider'), [searchParams])
const selectedType = useMemo(() => searchParams.get('type'), [searchParams])
+ const defaultSort = isAvailableToEarnTab ? 'yearly-return-desc' : 'apy-desc'
const sortOption = useMemo(
- () => (searchParams.get('sort') as SortOption) || 'apy-desc',
- [searchParams],
+ () => (searchParams.get('sort') as SortOption) || defaultSort,
+ [searchParams, defaultSort],
)
const sorting = useMemo(() => getSortingFromOption(sortOption), [sortOption])
diff --git a/src/react-queries/queries/yieldxyz/useYields.ts b/src/react-queries/queries/yieldxyz/useYields.ts
index bffc8202420..1c6d15c9942 100644
--- a/src/react-queries/queries/yieldxyz/useYields.ts
+++ b/src/react-queries/queries/yieldxyz/useYields.ts
@@ -174,6 +174,7 @@ export const useYields = (params?: UseYieldsParams) => {
return {
all: filtered,
+ unfiltered: allYields,
byId,
ids,
byAssetSymbol,