Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
- When creating commits, follow the Git Safety Protocol (see session notes)
- Main branch is `develop` - use this for PRs
- Branch naming: Use descriptive names (e.g., `feat_gridplus`, `fix_wallet_connect`)
- When opening PRs (via `gh`, Aviator `av`, or any CLI tool), ALWAYS use the `.github/PULL_REQUEST_TEMPLATE.md` template as the base for the PR body

### UI/UX Standards
- Account for light/dark mode using `useColorModeValue` hook
Expand Down
66 changes: 66 additions & 0 deletions YIELD_IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Yield Earn Flow Improvements

This document tracks all improvements made in the `feat_yield_full_toggle_1` PR.

---

## Yields Page

### Issue 3: Yield Routing 404s (High Priority)
**Problem:** Links navigating to `/yields/${yieldId}` resulted in 404s. The correct route is `/yield/${yieldId}` (singular).

**Files Modified:**
- `src/pages/Yields/components/YieldRelatedMarkets.tsx`
- `src/pages/Yields/components/YieldsList.tsx`
- `src/pages/Yields/YieldAssetDetails.tsx`

**Fix:** Changed `/yields/` to `/yield/` in navigation calls.

---

### Issue 4: Non-Default Validator Positions Showing (High Priority)
**Problem:** For Cosmos ATOM native staking, positions from non-ShapeShift DAO validators (e.g., Figment) were showing in the UI. Only ShapeShift DAO validator positions should be displayed.

**Files Modified:**
- `src/react-queries/queries/yieldxyz/useAllYieldBalances.ts` - Added filtering at data layer
- `src/pages/Yields/components/YieldsList.tsx` - Removed redundant validator filtering

**Fix:** Filter non-default validator positions at the data layer in `useAllYieldBalances`. For yields with a default validator defined in `DEFAULT_VALIDATOR_BY_YIELD_ID`, only balances from that validator are included. This applies to:
- Cosmos ATOM native staking: Only ShapeShift DAO validator positions
- Solana SOL multivalidator staking: Only Figment validator positions

---

## Yield Enter/Exit Modal

### Issue 1: Success Step Footer Dead Space (Medium Priority)
**Problem:** The success step in the Earn trade modal had dead/empty space below the "View Position" and "Close" buttons.

**Files Modified:**
- `src/pages/Yields/components/YieldSuccess.tsx` - Added `showButtons` prop
- `src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx` - Moved buttons to footerContent

**Fix:** Added `showButtons` prop to `YieldSuccess` component and moved buttons from body content to `footerContent` prop in `EarnConfirm.tsx`, matching the pattern used by input/confirm steps.

---

### Issue 2: Fiat Mode Placeholder Styled as Value (Low Priority)
**Problem:** In yield enter modals, when amount is 0 in fiat mode, "$0.00" was styled as an actual value instead of placeholder styling.

**Files Modified:**
- `src/pages/Yields/components/YieldEnterModal.tsx`
- `src/pages/Yields/components/YieldForm.tsx`

**Fix:** Return empty string when fiat amount is zero to trigger placeholder styling (`fiatAmount.isZero() ? '' : fiatAmount.toFixed(2)`).

---

### Issue 5: Re-access to /earn/confirm After Transaction Completes
**Problem:** After completing a yield enter transaction and navigating away (e.g., clicking "View position"), the user could go back to `/earn/confirm` which shouldn't be accessible anymore.

**Files Modified:**
- `src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx`

**Fix:**
1. Reorder guards - check for success state BEFORE checking for selectedYield, ensuring the success screen renders even if Redux state becomes undefined
2. Clear `tradeEarnInput` Redux state on unmount when in success state, preventing re-access via browser back button or navigation
1 change: 1 addition & 0 deletions src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@
"balance": "Balance",
"netWorth": "Net Worth",
"loadingAccounts": "Loaded %{portfolioAccountsLoaded} accounts",
"loadingMorePositions": "More DeFi positions are still loading",
"walletBalanceChange24Hr": "24-hour change",
"earnBody": "Earn passive income by staking your assets or depositing them into a DeFi strategy.",
"noAccountsOpportunities": "You have no accounts for this asset, so staking opportunities are currently unavailable.",
Expand Down
79 changes: 61 additions & 18 deletions src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Avatar, Box, Button, Flex, HStack, Skeleton, Text, VStack } from '@chakra-ui/react'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslate } from 'react-polyglot'
import { useNavigate } from 'react-router-dom'

Expand Down Expand Up @@ -33,11 +33,13 @@ import {
selectSelectedYieldId,
selectSellAccountId,
} from '@/state/slices/tradeEarnInputSlice/selectors'
import { useAppSelector } from '@/state/store'
import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice'
import { useAppDispatch, useAppSelector } from '@/state/store'

export const EarnConfirm = memo(() => {
const translate = useTranslate()
const navigate = useNavigate()
const dispatch = useAppDispatch()

const sellAsset = useAppSelector(selectInputSellAsset)
const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision)
Expand Down Expand Up @@ -141,6 +143,19 @@ export const EarnConfirm = memo(() => {
accountId: accountIdToUse,
})

// Track step in ref for cleanup
Comment thread
gomesalexandre marked this conversation as resolved.
const stepRef = useRef(step)
stepRef.current = step

// Clear Redux when unmounting from success state to prevent re-access
useEffect(() => {
return () => {
if (stepRef.current === ModalStep.Success) {
dispatch(tradeEarnInput.actions.clear())
}
}
}, [dispatch])

// Align loading states with YieldEnterModal
const isQuoteActive = isQuoteLoading || isAllowanceCheckPending
const isLoading = isLoadingYields || isQuoteActive
Expand Down Expand Up @@ -193,21 +208,13 @@ export const EarnConfirm = memo(() => {
return null
}, [selectedValidator, selectedYield, providers])

if (!selectedYield) {
return (
<SharedConfirm
bodyContent={
<VStack spacing={4} p={6} flex={1} justify='center'>
<Text>{translate('earn.selectYieldOpportunity')}</Text>
<Button onClick={handleBack}>{translate('common.goBack')}</Button>
</VStack>
}
footerContent={null}
onBack={handleBack}
headerTranslation='earn.confirmEarn'
/>
)
}
const handleViewPosition = useCallback(() => {
if (!selectedYieldId) return
const params = new URLSearchParams()
if (accountIdToUse) params.set('accountId', accountIdToUse)
const queryString = params.toString()
navigate(queryString ? `/yield/${selectedYieldId}?${queryString}` : `/yield/${selectedYieldId}`)
}, [selectedYieldId, accountIdToUse, navigate])

if (step === ModalStep.Success) {
return (
Expand All @@ -221,16 +228,52 @@ export const EarnConfirm = memo(() => {
transactionSteps={transactionSteps}
yieldId={selectedYieldId}
onDone={handleBack}
showButtons={false}
/>
</Flex>
}
footerContent={null}
footerContent={
<Box p={4}>
<VStack spacing={3} width='full'>
{selectedYieldId && (
<Button colorScheme='blue' size='lg' width='full' onClick={handleViewPosition}>
{translate('yieldXYZ.viewPosition')}
</Button>
)}
<Button
variant={selectedYieldId ? 'ghost' : 'solid'}
colorScheme={selectedYieldId ? undefined : 'blue'}
size='lg'
width='full'
onClick={handleBack}
>
{translate('common.close')}
</Button>
</VStack>
</Box>
}
onBack={handleBack}
headerTranslation='yieldXYZ.success'
/>
)
}

if (!selectedYield) {
return (
<SharedConfirm
bodyContent={
<VStack spacing={4} p={6} flex={1} justify='center'>
<Text>{translate('earn.selectYieldOpportunity')}</Text>
<Button onClick={handleBack}>{translate('common.goBack')}</Button>
</VStack>
}
footerContent={null}
onBack={handleBack}
headerTranslation='earn.confirmEarn'
/>
)
}

const bodyContent = (
<Flex direction='column' width='full' flex={1}>
<Box px={6}>
Expand Down
22 changes: 18 additions & 4 deletions src/components/StakingVaults/DeFiEarn.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { FlexProps, ResponsiveValue } from '@chakra-ui/react'
import { Box, Flex } from '@chakra-ui/react'
import { Box, Flex, Tooltip } from '@chakra-ui/react'
import type { ChainId } from '@shapeshiftoss/caip'
import { fromAssetId } from '@shapeshiftoss/caip'
import type { Property } from 'csstype'
import type { JSX } from 'react'
import { useMemo, useState } from 'react'
import { useTranslate } from 'react-polyglot'

import { GlobalFilter } from './GlobalFilter'
import { useFetchOpportunities } from './hooks/useFetchOpportunities'
Expand All @@ -13,6 +14,7 @@ import type { PositionTableProps, UnifiedOpportunity } from './PositionTable'
import { PositionTable } from './PositionTable'

import { ChainDropdown } from '@/components/ChainDropdown/ChainDropdown'
import { CircularProgress } from '@/components/CircularProgress/CircularProgress'
import { knownChainIds } from '@/constants/chains'
import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag'
import { useQuery } from '@/hooks/useQuery/useQuery'
Expand Down Expand Up @@ -41,6 +43,7 @@ export const DeFiEarn: React.FC<DefiEarnProps> = ({
forceCompactView,
...rest
}) => {
const translate = useTranslate()
const {
state: { isConnected },
} = useWallet()
Expand Down Expand Up @@ -107,7 +110,7 @@ export const DeFiEarn: React.FC<DefiEarnProps> = ({
return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds]))
}, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities])

const isLoading = isOpportunitiesLoading || (isYieldXyzEnabled && isYieldLoading)
const isTableLoading = isYieldXyzEnabled ? isYieldLoading : isOpportunitiesLoading

return (
<Flex width='full' flexDir='column' gap={6}>
Expand All @@ -129,8 +132,19 @@ export const DeFiEarn: React.FC<DefiEarnProps> = ({
showAll
includeBalance
/>
<Flex flex={1} maxWidth={globalFilterFlexMaxWidth} width='full' gap={4}>
<Flex
flex={1}
maxWidth={globalFilterFlexMaxWidth}
width='full'
gap={4}
alignItems='center'
>
<GlobalFilter setSearchQuery={setSearchQuery} searchQuery={searchQuery} />
{isOpportunitiesLoading && (
<Tooltip label={translate('defi.loadingMorePositions')}>
<CircularProgress size='5' />
</Tooltip>
)}
</Flex>
</Flex>
</Flex>
Expand All @@ -140,7 +154,7 @@ export const DeFiEarn: React.FC<DefiEarnProps> = ({
searchQuery={searchQuery}
forceCompactView={forceCompactView}
data={mergedData}
isLoading={isLoading}
isLoading={isTableLoading}
{...positionTableProps}
/>
</Box>
Expand Down
79 changes: 79 additions & 0 deletions src/lib/yieldxyz/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AugmentedYieldDto, ValidatorDto } from './types'
import {
ensureValidatorApr,
formatYieldTxTitle,
getBestActionableYield,
getDefaultValidatorForYield,
getTransactionButtonText,
getYieldActionLabelKeys,
Expand Down Expand Up @@ -384,3 +385,81 @@ describe('getDefaultValidatorForYield', () => {
expect(getDefaultValidatorForYield('some-random-yield')).toBeUndefined()
})
})

describe('getBestActionableYield', () => {
const createMockYield = (
id: string,
apy: number,
options: { enterDisabled?: boolean; underMaintenance?: boolean; deprecated?: boolean } = {},
): AugmentedYieldDto =>
({
id,
rewardRate: { total: apy, rateType: 'APY', components: [] },
status: { enter: !options.enterDisabled, exit: true },
metadata: {
name: `Yield ${id}`,
underMaintenance: options.underMaintenance ?? false,
deprecated: options.deprecated ?? false,
},
}) as unknown as AugmentedYieldDto

it('should return undefined for empty array', () => {
expect(getBestActionableYield([])).toBeUndefined()
})

it('should return undefined when all yields are disabled', () => {
const yields = [
createMockYield('a', 0.1, { enterDisabled: true }),
createMockYield('b', 0.2, { underMaintenance: true }),
createMockYield('c', 0.3, { deprecated: true }),
]
expect(getBestActionableYield(yields)).toBeUndefined()
})

it('should return highest APY yield when multiple are actionable', () => {
const yields = [
createMockYield('low', 0.05),
createMockYield('high', 0.15),
createMockYield('mid', 0.1),
]
const result = getBestActionableYield(yields)
expect(result?.id).toBe('high')
})

it('should filter out yields with enter disabled', () => {
const yields = [
createMockYield('disabled-high', 0.2, { enterDisabled: true }),
createMockYield('enabled-low', 0.05),
]
const result = getBestActionableYield(yields)
expect(result?.id).toBe('enabled-low')
})

it('should filter out yields under maintenance', () => {
const yields = [
createMockYield('maintenance-high', 0.2, { underMaintenance: true }),
createMockYield('active-low', 0.05),
]
const result = getBestActionableYield(yields)
expect(result?.id).toBe('active-low')
})

it('should filter out deprecated yields', () => {
const yields = [
createMockYield('deprecated-high', 0.2, { deprecated: true }),
createMockYield('active-low', 0.05),
]
const result = getBestActionableYield(yields)
expect(result?.id).toBe('active-low')
})

it('should return the only actionable yield', () => {
const yields = [
createMockYield('disabled', 0.3, { enterDisabled: true }),
createMockYield('only-active', 0.1),
createMockYield('maintenance', 0.25, { underMaintenance: true }),
]
const result = getBestActionableYield(yields)
expect(result?.id).toBe('only-active')
})
})
12 changes: 12 additions & 0 deletions src/lib/yieldxyz/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from './constants'
import type { AugmentedYieldDto, ValidatorDto, YieldIconSource, YieldType } from './types'

import { bnOrZero } from '@/lib/bignumber/bignumber'

export const yieldNetworkToChainId = (network: string): ChainId | undefined => {
if (!isSupportedYieldNetwork(network)) return undefined
return YIELD_NETWORK_TO_CHAIN_ID[network]
Expand Down Expand Up @@ -350,3 +352,13 @@ export const isYieldDisabled = (
yieldItem: Pick<AugmentedYieldDto, 'status' | 'metadata'>,
): boolean =>
!yieldItem.status.enter || yieldItem.metadata.underMaintenance || yieldItem.metadata.deprecated

export const getBestActionableYield = (
yields: AugmentedYieldDto[],
): AugmentedYieldDto | undefined => {
const actionable = yields.filter(y => !isYieldDisabled(y))
if (actionable.length === 0) return undefined
return actionable.reduce((best, current) =>
bnOrZero(current.rewardRate.total).gt(best.rewardRate.total) ? current : best,
)
}
2 changes: 1 addition & 1 deletion src/pages/Yields/YieldAssetDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ export const YieldAssetDetails = memo(() => {
const handleYieldClick = useCallback(
(yieldId: string) => {
const validator = getDefaultValidatorForYield(yieldId)
const url = validator ? `/yields/${yieldId}?validator=${validator}` : `/yields/${yieldId}`
const url = validator ? `/yield/${yieldId}?validator=${validator}` : `/yield/${yieldId}`
navigate(url)
},
[navigate],
Expand Down
Loading