From 8f7e62652c3414e8b9b42272f901450a0a744c69 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:34:08 -1000 Subject: [PATCH 01/17] feat: prevent re-access to /earn/confirm after yield transaction completes After completing a yield enter transaction and navigating away (e.g., clicking "View position"), the user could previously go back to /earn/confirm which shouldn't be accessible anymore. Fixes: 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 Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnConfirm.tsx | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 74fc903b872..b65b6f8c3c9 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -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' @@ -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) @@ -141,6 +143,19 @@ export const EarnConfirm = memo(() => { accountId: accountIdToUse, }) + // Track step in ref for cleanup + 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 @@ -193,22 +208,6 @@ export const EarnConfirm = memo(() => { return null }, [selectedValidator, selectedYield, providers]) - if (!selectedYield) { - return ( - - {translate('earn.selectYieldOpportunity')} - - - } - footerContent={null} - onBack={handleBack} - headerTranslation='earn.confirmEarn' - /> - ) - } - if (step === ModalStep.Success) { return ( { ) } + if (!selectedYield) { + return ( + + {translate('earn.selectYieldOpportunity')} + + + } + footerContent={null} + onBack={handleBack} + headerTranslation='earn.confirmEarn' + /> + ) + } + const bodyContent = ( From c27a523e697832e83e6ee785784e3be059b1c1d6 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:44:08 -1000 Subject: [PATCH 02/17] fix: correct yield routing from /yields/ to /yield/ Links were navigating to /yields/${yieldId} which resulted in 404s. The correct route is /yield/${yieldId} (singular). Co-Authored-By: Claude Opus 4.5 --- YIELD_IMPROVEMENTS.md | 23 +++++++++++++++++++ src/pages/Yields/YieldAssetDetails.tsx | 2 +- .../Yields/components/YieldRelatedMarkets.tsx | 2 +- src/pages/Yields/components/YieldsList.tsx | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 YIELD_IMPROVEMENTS.md diff --git a/YIELD_IMPROVEMENTS.md b/YIELD_IMPROVEMENTS.md new file mode 100644 index 00000000000..c0d40816656 --- /dev/null +++ b/YIELD_IMPROVEMENTS.md @@ -0,0 +1,23 @@ +# Yield Earn Flow Improvements + +## Completed + +### 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` - line 40 +- `src/pages/Yields/components/YieldsList.tsx` - line 561 +- `src/pages/Yields/YieldAssetDetails.tsx` - line 394 + +**Fix:** Changed `/yields/` to `/yield/` in navigation calls. + +--- + +## Pending + +### Issue 1: Success Step Footer Dead Space (Medium Priority) +Move success buttons from YieldSuccess body into footerContent prop to eliminate dead space below buttons. + +### Issue 2: Fiat Mode Placeholder Styled as Value (Low Priority) +Fix fiat mode showing "$0.00" as actual value instead of placeholder styling. diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index ce6f6c63a61..a796c5ff854 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -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], diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx index fc536021662..ac58cf40b30 100644 --- a/src/pages/Yields/components/YieldRelatedMarkets.tsx +++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx @@ -37,7 +37,7 @@ export const YieldRelatedMarkets = memo( const handleYieldClick = useCallback( (yieldId: string) => { - navigate(`/yields/${yieldId}`) + navigate(`/yield/${yieldId}`) }, [navigate], ) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 93124222c02..b82aaeeb5e2 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -558,7 +558,7 @@ export const YieldsList = 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], From 530d6b5b848ecd4ba8ea3c4e51c077a5124b9a08 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:55:36 -1000 Subject: [PATCH 03/17] fix: eliminate dead space in yield success step footer Moved success buttons from YieldSuccess body content to footerContent prop in EarnConfirm, matching the pattern used by input/confirm steps. Added showButtons prop to YieldSuccess for backwards compatibility. Co-Authored-By: Claude Opus 4.5 --- YIELD_IMPROVEMENTS.md | 14 ++++++-- .../components/Earn/EarnConfirm.tsx | 30 +++++++++++++++- src/pages/Yields/components/YieldSuccess.tsx | 34 +++++++++++-------- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/YIELD_IMPROVEMENTS.md b/YIELD_IMPROVEMENTS.md index c0d40816656..60455c27d66 100644 --- a/YIELD_IMPROVEMENTS.md +++ b/YIELD_IMPROVEMENTS.md @@ -14,10 +14,18 @@ --- -## Pending - ### Issue 1: Success Step Footer Dead Space (Medium Priority) -Move success buttons from YieldSuccess body into footerContent prop to eliminate dead space below buttons. +**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. + +--- + +## Pending ### Issue 2: Fiat Mode Placeholder Styled as Value (Low Priority) Fix fiat mode showing "$0.00" as actual value instead of placeholder styling. diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index b65b6f8c3c9..e3328675b08 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -208,6 +208,14 @@ export const EarnConfirm = memo(() => { return null }, [selectedValidator, selectedYield, providers]) + 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 ( { transactionSteps={transactionSteps} yieldId={selectedYieldId} onDone={handleBack} + showButtons={false} /> } - footerContent={null} + footerContent={ + + + {selectedYieldId && ( + + )} + + + + } onBack={handleBack} headerTranslation='yieldXYZ.success' /> diff --git a/src/pages/Yields/components/YieldSuccess.tsx b/src/pages/Yields/components/YieldSuccess.tsx index d65cc662777..b4f0119bf2e 100644 --- a/src/pages/Yields/components/YieldSuccess.tsx +++ b/src/pages/Yields/components/YieldSuccess.tsx @@ -27,6 +27,7 @@ type YieldSuccessProps = { onDone: () => void showConfetti?: boolean successMessageKey?: YieldSuccessMessageKey + showButtons?: boolean } export const YieldSuccess = memo( @@ -40,6 +41,7 @@ export const YieldSuccess = memo( onDone, showConfetti = true, successMessageKey = 'successStaked', + showButtons = true, }: YieldSuccessProps) => { const translate = useTranslate() const navigate = useNavigate() @@ -124,22 +126,24 @@ export const YieldSuccess = memo( )} - - {yieldId && ( - + )} + - )} - - + + )} ) From e2e5cc44668512c991e664d713303a0ae9511ea8 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:00:10 -1000 Subject: [PATCH 04/17] fix: style fiat zero amount as placeholder in yield modals When fiat amount is zero, return empty string instead of '0.00' to trigger placeholder styling (greyed out) matching crypto mode behavior. Co-Authored-By: Claude Opus 4.5 --- YIELD_IMPROVEMENTS.md | 10 +++++++--- src/pages/Yields/components/YieldEnterModal.tsx | 2 +- src/pages/Yields/components/YieldForm.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/YIELD_IMPROVEMENTS.md b/YIELD_IMPROVEMENTS.md index 60455c27d66..1d274b108f1 100644 --- a/YIELD_IMPROVEMENTS.md +++ b/YIELD_IMPROVEMENTS.md @@ -25,7 +25,11 @@ --- -## Pending - ### Issue 2: Fiat Mode Placeholder Styled as Value (Low Priority) -Fix fiat mode showing "$0.00" as actual value instead of placeholder styling. +**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` - line 276 +- `src/pages/Yields/components/YieldForm.tsx` - line 338 + +**Fix:** Return empty string when fiat amount is zero to trigger placeholder styling (`fiatAmount.isZero() ? '' : fiatAmount.toFixed(2)`). diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 7f3f0774577..5e878355523 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -273,7 +273,7 @@ export const YieldEnterModal = memo( const displayValue = useMemo(() => { if (isFiat) { - return fiatAmount.toFixed(2) + return fiatAmount.isZero() ? '' : fiatAmount.toFixed(2) } return cryptoAmount }, [isFiat, fiatAmount, cryptoAmount]) diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index a1755511a65..12c1cdcf472 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -335,7 +335,7 @@ export const YieldForm = memo( const displayValue = useMemo(() => { if (isFiat) { - return fiatAmount.toFixed(2) + return fiatAmount.isZero() ? '' : fiatAmount.toFixed(2) } return cryptoAmount }, [isFiat, fiatAmount, cryptoAmount]) From 01fe19ba531b6e60ee405fb84d9756b024fccb96 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:10:21 -1000 Subject: [PATCH 05/17] fix: filter non-default validator positions at data layer For yields with a default validator (Cosmos ATOM, Solana SOL native staking), filter out positions from other validators in useAllYieldBalances query. This ensures only ShapeShift DAO positions show for Cosmos and only Figment positions show for Solana, hiding positions staked externally with other validators. Removed redundant validator-specific filtering from YieldsList.tsx since filtering now happens at the data layer. Co-Authored-By: Claude Opus 4.5 --- YIELD_IMPROVEMENTS.md | 43 ++++++++++++++++--- src/pages/Yields/components/YieldsList.tsx | 24 +---------- .../queries/yieldxyz/useAllYieldBalances.ts | 11 ++++- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/YIELD_IMPROVEMENTS.md b/YIELD_IMPROVEMENTS.md index 1d274b108f1..603e23dfff6 100644 --- a/YIELD_IMPROVEMENTS.md +++ b/YIELD_IMPROVEMENTS.md @@ -1,19 +1,38 @@ # Yield Earn Flow Improvements -## Completed +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` - line 40 -- `src/pages/Yields/components/YieldsList.tsx` - line 561 -- `src/pages/Yields/YieldAssetDetails.tsx` - line 394 +- `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. @@ -29,7 +48,19 @@ **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` - line 276 -- `src/pages/Yields/components/YieldForm.tsx` - line 338 +- `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 diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index b82aaeeb5e2..380c5284f5a 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -35,10 +35,8 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import { fromBaseUnit } from '@/lib/math' import { COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, - FIGMENT_SOLANA_VALIDATOR_ADDRESS, FIGMENT_VALIDATOR_LOGO, FIGMENT_VALIDATOR_NAME, - SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME, SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, @@ -212,27 +210,7 @@ export const YieldsList = memo(() => { const getYieldPositionBalanceUsd = useCallback( (yieldId: string) => { const yieldBalances = allBalances?.[yieldId] - if (!yieldBalances) return undefined - - // For Cosmos native staking, only show ShapeShift DAO validator balance - if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { - const filteredBalances = yieldBalances.filter( - b => b.validator?.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, - ) - if (filteredBalances.length === 0) return undefined - return filteredBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - } - - // For Solana native multivalidator staking, only show Figment validator balance - if (yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID) { - const filteredBalances = yieldBalances.filter( - b => b.validator?.address === FIGMENT_SOLANA_VALIDATOR_ADDRESS, - ) - if (filteredBalances.length === 0) return undefined - return filteredBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) - } - - // For other yields, sum all balances + if (!yieldBalances || yieldBalances.length === 0) return undefined return yieldBalances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)) }, [allBalances], diff --git a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts index b98920d9a8b..7a5105d6807 100644 --- a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts +++ b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts @@ -8,7 +8,11 @@ import { bnOrZero } from '@/lib/bignumber/bignumber' import { isSome } from '@/lib/utils' import { fetchAggregateBalances } from '@/lib/yieldxyz/api' import { augmentYieldBalances } from '@/lib/yieldxyz/augment' -import { CHAIN_ID_TO_YIELD_NETWORK, SUPPORTED_YIELD_NETWORKS } from '@/lib/yieldxyz/constants' +import { + CHAIN_ID_TO_YIELD_NETWORK, + DEFAULT_VALIDATOR_BY_YIELD_ID, + SUPPORTED_YIELD_NETWORKS, +} from '@/lib/yieldxyz/constants' import type { AugmentedYieldBalance, YieldBalanceType, @@ -312,6 +316,11 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => } for (const balance of augmentedBalances) { + const defaultValidator = DEFAULT_VALIDATOR_BY_YIELD_ID[item.yieldId] + if (defaultValidator && balance.validator?.address !== defaultValidator) { + continue + } + const network = item.yieldId.split('-')[0] const lookupKey = `${balance.address.toLowerCase()}:${network}` let accountId = addressToAccountId[lookupKey] From 36a34a36870eeed54bfb80242d18472833001110 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:21:05 -1000 Subject: [PATCH 06/17] fix: show provider row for liquid staking yields Liquid staking yields (like ETH Lido) were missing the Provider row because they are classified as staking but have no validators. The condition now shows the provider when there's no validator metadata. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/components/YieldEnterModal.tsx | 2 +- src/pages/Yields/components/YieldForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 5e878355523..11b492c8acd 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -489,7 +489,7 @@ export const YieldEnterModal = memo( )} - {!isStaking && providerMetadata && ( + {(!isStaking || !selectedValidatorMetadata) && providerMetadata && ( {translate('yieldXYZ.provider')} diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 12c1cdcf472..8c3efab1c0e 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -587,7 +587,7 @@ export const YieldForm = memo( )} - {!isStaking && maybeProviderMetadata && ( + {(!isStaking || !maybeSelectedValidatorMetadata) && maybeProviderMetadata && ( {translate('yieldXYZ.provider')} From bb00234f6cb90af888bf9504dc77954465b7692b Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:27:32 -1000 Subject: [PATCH 07/17] chore: add PR template rule to CLAUDE.md Document that PRs opened via CLI tools should always use the PULL_REQUEST_TEMPLATE.md as the base for the PR body. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4beb4e217f3..d680613311a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 From 1df6077564a74fdfaa0f68d3a581a05011819e9c Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:08:45 -1000 Subject: [PATCH 08/17] fix: add spinner loading state for legacy positions in DeFi earn Shows a loading spinner with tooltip when legacy positions are still loading, allowing the yield table to render immediately with yield.xyz data. Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + src/components/StakingVaults/DeFiEarn.tsx | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index cbe48ce44ba..f49cb5cb90b 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -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.", diff --git a/src/components/StakingVaults/DeFiEarn.tsx b/src/components/StakingVaults/DeFiEarn.tsx index 21235f932f0..5ef0f4457d3 100644 --- a/src/components/StakingVaults/DeFiEarn.tsx +++ b/src/components/StakingVaults/DeFiEarn.tsx @@ -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' @@ -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' @@ -41,6 +43,7 @@ export const DeFiEarn: React.FC = ({ forceCompactView, ...rest }) => { + const translate = useTranslate() const { state: { isConnected }, } = useWallet() @@ -107,7 +110,7 @@ export const DeFiEarn: React.FC = ({ return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds])) }, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities]) - const isLoading = isOpportunitiesLoading || (isYieldXyzEnabled && isYieldLoading) + const isTableLoading = isYieldXyzEnabled ? isYieldLoading : isOpportunitiesLoading return ( @@ -129,8 +132,19 @@ export const DeFiEarn: React.FC = ({ showAll includeBalance /> - + + {isOpportunitiesLoading && ( + + + + )} @@ -140,7 +154,7 @@ export const DeFiEarn: React.FC = ({ searchQuery={searchQuery} forceCompactView={forceCompactView} data={mergedData} - isLoading={isLoading} + isLoading={isTableLoading} {...positionTableProps} /> From 52840137b3b322678618d98e66b807eb4a0fff32 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:08:52 -1000 Subject: [PATCH 09/17] fix: select best actionable yield for asset page CTA Adds getBestActionableYield utility that filters out disabled yields (enter disabled, under maintenance, deprecated) before selecting the highest APY option. Prevents showing CTAs for opportunities users can't act on. Co-Authored-By: Claude Opus 4.5 --- src/lib/yieldxyz/utils.test.ts | 79 +++++++++++++++++++ src/lib/yieldxyz/utils.ts | 12 +++ .../Yields/components/YieldAssetSection.tsx | 8 +- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/lib/yieldxyz/utils.test.ts b/src/lib/yieldxyz/utils.test.ts index 3dac14a8313..a43d87a5262 100644 --- a/src/lib/yieldxyz/utils.test.ts +++ b/src/lib/yieldxyz/utils.test.ts @@ -5,6 +5,7 @@ import type { AugmentedYieldDto, ValidatorDto } from './types' import { ensureValidatorApr, formatYieldTxTitle, + getBestActionableYield, getDefaultValidatorForYield, getTransactionButtonText, getYieldActionLabelKeys, @@ -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') + }) +}) diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 7b20aee6e15..112eb34a782 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -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] @@ -350,3 +352,13 @@ export const isYieldDisabled = ( yieldItem: Pick, ): 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, + ) +} diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index b8f1a10ee1d..52c799957c5 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -12,6 +12,7 @@ import { YieldOpportunityCard } from './YieldOpportunityCard' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from '@/hooks/useWallet/useWallet' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getBestActionableYield } from '@/lib/yieldxyz/utils' import type { AugmentedYieldBalanceWithAccountId } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' @@ -81,12 +82,7 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection return Object.keys(result).length > 0 ? result : undefined }, [allBalancesData, yields, accountId]) - const sortedYields = useMemo( - () => [...yields].sort((a, b) => b.rewardRate.total - a.rewardRate.total), - [yields], - ) - - const bestYield = sortedYields[0] + const bestYield = useMemo(() => getBestActionableYield(yields), [yields]) const hasActivePositions = Boolean(filteredBalancesByYieldId) From c4bb8b8b58d33302939277380d7f2fe37e6f8bb5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:18:23 -1000 Subject: [PATCH 10/17] fix: aggregate yield balances across all accounts in drawer When DeFiEarn is rendered outside a YieldAccountProvider (like in the wallet drawer), the default accountNumber: 0 was incorrectly filtering to only Account #0's balances. Now falls through to enabledWalletAccountIds when no context is present, properly aggregating balances across all accounts. Co-Authored-By: Claude Opus 4.5 --- .../queries/yieldxyz/useAllYieldBalances.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts index 7a5105d6807..261c676c605 100644 --- a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts +++ b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts @@ -5,7 +5,6 @@ import { useMemo } from 'react' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { isSome } from '@/lib/utils' import { fetchAggregateBalances } from '@/lib/yieldxyz/api' import { augmentYieldBalances } from '@/lib/yieldxyz/augment' import { @@ -21,10 +20,7 @@ import type { } from '@/lib/yieldxyz/types' import { YieldBalanceType as YieldBalanceTypeEnum } from '@/lib/yieldxyz/types' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' -import { - selectAccountIdsByAccountNumberAndChainId, - selectEnabledWalletAccountIds, -} from '@/state/slices/selectors' +import { selectEnabledWalletAccountIds } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' type UseAllYieldBalancesOptions = { @@ -228,25 +224,14 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => const isEnabled = enabled ?? true const { state: walletState } = useWallet() const isConnected = Boolean(walletState.walletInfo) - const { accountId: contextAccountId, accountNumber } = useYieldAccount() - const accountIdsByAccountNumberAndChainId = useAppSelector( - selectAccountIdsByAccountNumberAndChainId, - ) + const { accountId: contextAccountId } = useYieldAccount() const enabledWalletAccountIds = useAppSelector(selectEnabledWalletAccountIds) - const accountIdsForAccountNumber = useMemo((): AccountId[] => { - if (accountNumber === undefined) return [] - const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] - if (!byChainId) return [] - return Object.values(byChainId).flat().filter(isSome) - }, [accountIdsByAccountNumberAndChainId, accountNumber]) - const targetAccountIds: AccountId[] = useMemo(() => { if (filterAccountIds?.length) return filterAccountIds if (contextAccountId) return [contextAccountId] - if (accountIdsForAccountNumber.length) return accountIdsForAccountNumber return enabledWalletAccountIds - }, [filterAccountIds, contextAccountId, accountIdsForAccountNumber, enabledWalletAccountIds]) + }, [filterAccountIds, contextAccountId, enabledWalletAccountIds]) const queryPayloads = useMemo(() => { if (!isConnected || targetAccountIds.length === 0) return [] From 0be32587c2b4a32e776c71eee091af15db6a051b Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:37:14 -1000 Subject: [PATCH 11/17] refactor: yield domain code improvements - Wrap DeFiEarn in memo to prevent unnecessary re-renders - Remove empty useEffect in YieldForm - Consolidate isStakingYieldType utility (remove redundant wrapper) - Extract CryptoAmountInput to shared component - Extract useYieldDisplayInfo hook from YieldsList - Move static searchIcon outside component - Fix highestAmountUsdValidator computed after validator filtering - Remove YIELD_IMPROVEMENTS.md dev notes file Co-Authored-By: Claude Opus 4.5 --- YIELD_IMPROVEMENTS.md | 66 ----- .../CryptoAmountInput/CryptoAmountInput.tsx | 44 ++++ src/components/StakingVaults/DeFiEarn.tsx | 237 +++++++++--------- src/lib/yieldxyz/utils.ts | 11 +- src/pages/Yields/components/YieldForm.tsx | 52 +--- src/pages/Yields/components/YieldsList.tsx | 54 +--- src/pages/Yields/hooks/useYieldDisplayInfo.ts | 62 +++++ .../queries/yieldxyz/useAllYieldBalances.ts | 14 +- 8 files changed, 240 insertions(+), 300 deletions(-) delete mode 100644 YIELD_IMPROVEMENTS.md create mode 100644 src/components/CryptoAmountInput/CryptoAmountInput.tsx create mode 100644 src/pages/Yields/hooks/useYieldDisplayInfo.ts diff --git a/YIELD_IMPROVEMENTS.md b/YIELD_IMPROVEMENTS.md deleted file mode 100644 index 603e23dfff6..00000000000 --- a/YIELD_IMPROVEMENTS.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 diff --git a/src/components/CryptoAmountInput/CryptoAmountInput.tsx b/src/components/CryptoAmountInput/CryptoAmountInput.tsx new file mode 100644 index 00000000000..8e5ce23fe2c --- /dev/null +++ b/src/components/CryptoAmountInput/CryptoAmountInput.tsx @@ -0,0 +1,44 @@ +import { Input } from '@chakra-ui/react' +import type { ChangeEvent } from 'react' +import { memo, useMemo } from 'react' + +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + FOR_MD_FONT: 10, +} as const + +const getInputFontSize = (length: number): string => { + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_XS_FONT) return '24px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_SM_FONT) return '30px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_MD_FONT) return '38px' + return '48px' +} + +export type CryptoAmountInputProps = { + value?: string + onChange?: (e: ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +export const CryptoAmountInput = memo((props: CryptoAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) + + return ( + + ) +}) diff --git a/src/components/StakingVaults/DeFiEarn.tsx b/src/components/StakingVaults/DeFiEarn.tsx index 5ef0f4457d3..e6920881c6b 100644 --- a/src/components/StakingVaults/DeFiEarn.tsx +++ b/src/components/StakingVaults/DeFiEarn.tsx @@ -4,7 +4,7 @@ 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 { memo, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { GlobalFilter } from './GlobalFilter' @@ -37,127 +37,124 @@ const flexPaddingX = { base: 2, xl: 0 } const globalFilterFlexMaxWidth = { base: '100%', md: '300px' } const tablePx = { base: 0, md: 0 } -export const DeFiEarn: React.FC = ({ - positionTableProps, - header, - forceCompactView, - ...rest -}) => { - const translate = useTranslate() - const { - state: { isConnected }, - } = useWallet() - const { q } = useQuery<{ q?: string }>() - const [searchQuery, setSearchQuery] = useState(q ?? '') - const [selectedChainId, setSelectedChainId] = useState() - - const isYieldXyzEnabled = useFeatureFlag('YieldXyz') - - const chainIdsFromWallet = useAppSelector(state => - isConnected ? selectWalletConnectedChainIdsSorted(state) : knownChainIds, - ) - - const { isLoading: isOpportunitiesLoading } = useFetchOpportunities() - const legacyPositions = useAppSelector(state => - selectAggregatedEarnOpportunitiesByAssetId(state, { chainId: undefined }), - ) - - const { data: yieldOpportunities, isLoading: isYieldLoading } = - useYieldAsOpportunities(isYieldXyzEnabled) - - const mergedData: UnifiedOpportunity[] = useMemo(() => { - const map = new Map() - - if (isYieldXyzEnabled && yieldOpportunities) { - yieldOpportunities.forEach(item => { - map.set(item.assetId, item) - }) - } - - legacyPositions.forEach(item => { - const existing = map.get(item.assetId) - if (existing) { - const mergedFiatAmount = bnOrZero(existing.fiatAmount) - .plus(bnOrZero(item.fiatAmount)) - .toFixed(2) - const mergedApy = bnOrZero(existing.apy).gt(bnOrZero(item.apy)) ? existing.apy : item.apy - map.set(item.assetId, { - ...existing, - fiatAmount: mergedFiatAmount, - apy: mergedApy, - opportunities: { - staking: [...existing.opportunities.staking, ...item.opportunities.staking], - lp: [...existing.opportunities.lp, ...item.opportunities.lp], - }, +export const DeFiEarn = memo( + ({ positionTableProps, header, forceCompactView, ...rest }: DefiEarnProps) => { + const translate = useTranslate() + const { + state: { isConnected }, + } = useWallet() + const { q } = useQuery<{ q?: string }>() + const [searchQuery, setSearchQuery] = useState(q ?? '') + const [selectedChainId, setSelectedChainId] = useState() + + const isYieldXyzEnabled = useFeatureFlag('YieldXyz') + + const chainIdsFromWallet = useAppSelector(state => + isConnected ? selectWalletConnectedChainIdsSorted(state) : knownChainIds, + ) + + const { isLoading: isOpportunitiesLoading } = useFetchOpportunities() + const legacyPositions = useAppSelector(state => + selectAggregatedEarnOpportunitiesByAssetId(state, { chainId: undefined }), + ) + + const { data: yieldOpportunities, isLoading: isYieldLoading } = + useYieldAsOpportunities(isYieldXyzEnabled) + + const mergedData: UnifiedOpportunity[] = useMemo(() => { + const map = new Map() + + if (isYieldXyzEnabled && yieldOpportunities) { + yieldOpportunities.forEach(item => { + map.set(item.assetId, item) }) - } else { - map.set(item.assetId, item as UnifiedOpportunity) } - }) - - 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(() => { - if (!isYieldXyzEnabled || !yieldOpportunities?.length) return chainIdsFromWallet - const yieldChainIds = yieldOpportunities - .map(item => fromAssetId(item.assetId).chainId) - .filter(Boolean) - return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds])) - }, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities]) - - const isTableLoading = isYieldXyzEnabled ? isYieldLoading : isOpportunitiesLoading - - return ( - - - {header && header} - - - - - {isOpportunitiesLoading && ( - - - - )} + + legacyPositions.forEach(item => { + const existing = map.get(item.assetId) + if (existing) { + const mergedFiatAmount = bnOrZero(existing.fiatAmount) + .plus(bnOrZero(item.fiatAmount)) + .toFixed(2) + const mergedApy = bnOrZero(existing.apy).gt(bnOrZero(item.apy)) ? existing.apy : item.apy + map.set(item.assetId, { + ...existing, + fiatAmount: mergedFiatAmount, + apy: mergedApy, + opportunities: { + staking: [...existing.opportunities.staking, ...item.opportunities.staking], + lp: [...existing.opportunities.lp, ...item.opportunities.lp], + }, + }) + } else { + map.set(item.assetId, item as UnifiedOpportunity) + } + }) + + 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(() => { + if (!isYieldXyzEnabled || !yieldOpportunities?.length) return chainIdsFromWallet + const yieldChainIds = yieldOpportunities + .map(item => fromAssetId(item.assetId).chainId) + .filter(Boolean) + return Array.from(new Set([...chainIdsFromWallet, ...yieldChainIds])) + }, [chainIdsFromWallet, isYieldXyzEnabled, yieldOpportunities]) + + const isTableLoading = isYieldXyzEnabled ? isYieldLoading : isOpportunitiesLoading + + return ( + + + {header && header} + + + + + {isOpportunitiesLoading && ( + + + + )} + + + + - - - - - ) -} + ) + }, +) diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 112eb34a782..00c25690152 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -66,7 +66,7 @@ const TX_TYPE_TO_LABELS: Record = { type TerminologyKey = 'staking' | 'vault' -const isStakingType = (yieldType: YieldType): boolean => { +export const isStakingYieldType = (yieldType: YieldType): boolean => { switch (yieldType) { case 'staking': case 'native-staking': @@ -78,7 +78,6 @@ const isStakingType = (yieldType: YieldType): boolean => { case 'lending': return false default: - // This shouldn't happen but satisfies exhaustiveness check assertNever(yieldType) return false } @@ -93,7 +92,7 @@ export const getTransactionButtonText = ( title: string | undefined, yieldType?: YieldType, ): string => { - const labelKey: TerminologyKey = yieldType && isStakingType(yieldType) ? 'staking' : 'vault' + const labelKey: TerminologyKey = yieldType && isStakingYieldType(yieldType) ? 'staking' : 'vault' if (type) { const normalized = type.toUpperCase().replace(/[_-]/g, '_') @@ -115,7 +114,7 @@ export const formatYieldTxTitle = ( assetSymbol: string, yieldType?: YieldType, ): string => { - const labelKey: TerminologyKey = yieldType && isStakingType(yieldType) ? 'staking' : 'vault' + const labelKey: TerminologyKey = yieldType && isStakingYieldType(yieldType) ? 'staking' : 'vault' const normalized = title.replace(/ transaction$/i, '').toLowerCase() const match = TX_TITLE_PATTERNS.find(p => p.pattern.test(normalized)) @@ -316,10 +315,6 @@ export const getYieldMinAmountKey = (yieldType: YieldType): string => { } } -export const isStakingYieldType = (yieldType: YieldType): boolean => { - return isStakingType(yieldType) -} - export type YieldSuccessMessageKey = | 'successStaked' | 'successUnstaked' diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 8c3efab1c0e..41e5c61fab6 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -8,14 +8,12 @@ import { Flex, HStack, Icon, - Input, Skeleton, Text, } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' -import type { ChangeEvent } from 'react' -import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' import { NumericFormat } from 'react-number-format' @@ -24,6 +22,7 @@ import { useTranslate } from 'react-polyglot' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { Amount } from '@/components/Amount/Amount' import { AssetIcon } from '@/components/AssetIcon' +import { CryptoAmountInput } from '@/components/CryptoAmountInput/CryptoAmountInput' import { WalletActions } from '@/context/WalletProvider/actions' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' @@ -77,47 +76,6 @@ type YieldFormProps = { const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const -const INPUT_LENGTH_BREAKPOINTS = { - FOR_XS_FONT: 22, - FOR_SM_FONT: 14, - FOR_MD_FONT: 10, -} as const - -const getInputFontSize = (length: number): string => { - if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_XS_FONT) return '24px' - if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_SM_FONT) return '30px' - if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_MD_FONT) return '38px' - return '48px' -} - -type CryptoAmountInputProps = { - value?: string - onChange?: (e: ChangeEvent) => void - placeholder?: string - [key: string]: unknown -} - -const CryptoAmountInput = (props: CryptoAmountInputProps) => { - const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) - const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) - - return ( - - ) -} - const YieldFormSkeleton = memo(() => ( @@ -402,12 +360,6 @@ export const YieldForm = memo( const isQuoteActive = isQuoteLoading || isAllowanceCheckPending - useEffect(() => { - if (step === ModalStep.Success) { - // Here we could auto-close or let YieldSuccess handle it - } - }, [step]) - const maybeSuccessProviderInfo = useMemo(() => { if (isStaking && maybeSelectedValidatorMetadata) { return { diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 380c5284f5a..6ca88253e6c 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -33,19 +33,10 @@ import { ResultsEmptyNoWallet } from '@/components/ResultsEmptyNoWallet' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { fromBaseUnit } from '@/lib/math' -import { - COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, - FIGMENT_VALIDATOR_LOGO, - FIGMENT_VALIDATOR_NAME, - SHAPESHIFT_VALIDATOR_LOGO, - SHAPESHIFT_VALIDATOR_NAME, - SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, - YIELD_NETWORK_TO_CHAIN_ID, -} from '@/lib/yieldxyz/constants' +import { YIELD_NETWORK_TO_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' import { getDefaultValidatorForYield, - isStakingYieldType, isYieldDisabled, resolveYieldInputAssetIcon, searchYields, @@ -55,6 +46,7 @@ import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldIte import { YieldOpportunityStats } from '@/pages/Yields/components/YieldOpportunityStats' import { YieldTable } from '@/pages/Yields/components/YieldTable' import { ViewToggle } from '@/pages/Yields/components/YieldViewHelpers' +import { useYieldDisplayInfo } from '@/pages/Yields/hooks/useYieldDisplayInfo' import { useYieldFilters } from '@/pages/Yields/hooks/useYieldFilters' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' @@ -69,6 +61,7 @@ import { import { useAppSelector } from '@/state/store' const tabSelectedSx = { color: 'white', bg: 'blue.500' } +const searchIcon = const TAB_PARAMS = ['all', 'available', 'my-positions'] as const type YieldTab = (typeof TAB_PARAMS)[number] @@ -138,6 +131,7 @@ export const YieldsList = memo(() => { }) const allBalances = allBalancesData?.byYieldId const { data: yieldProviders } = useYieldProviders() + const getYieldDisplayInfo = useYieldDisplayInfo(yieldProviders) const handleTabChange = useCallback( (index: number) => { @@ -171,42 +165,6 @@ export const YieldsList = memo(() => { [yieldProviders], ) - const getYieldDisplayInfo = useCallback( - (yieldItem: AugmentedYieldDto) => { - const isNativeStaking = - isStakingYieldType(yieldItem.mechanics.type) && - yieldItem.mechanics.requiresValidatorSelection - - if (yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { - return { - name: SHAPESHIFT_VALIDATOR_NAME, - logoURI: SHAPESHIFT_VALIDATOR_LOGO, - title: translate('yieldXYZ.nativeStaking'), - } - } - if ( - yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || - (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) - ) { - return { - name: FIGMENT_VALIDATOR_NAME, - logoURI: FIGMENT_VALIDATOR_LOGO, - title: translate('yieldXYZ.nativeStaking'), - } - } - if (isNativeStaking) { - return { - name: yieldItem.metadata.name, - logoURI: yieldItem.metadata.logoURI, - title: translate('yieldXYZ.nativeStaking'), - } - } - const provider = yieldProviders?.[yieldItem.providerId] - return { name: provider?.name, logoURI: provider?.logoURI } - }, - [translate, yieldProviders], - ) - const getYieldPositionBalanceUsd = useCallback( (yieldId: string) => { const yieldBalances = allBalances?.[yieldId] @@ -1190,9 +1148,7 @@ export const YieldsList = memo(() => { direction={{ base: 'column', md: 'row' }} > - - - + {searchIcon} | undefined) => { + const translate = useTranslate() + + return useCallback( + (yieldItem: AugmentedYieldDto): YieldDisplayInfo => { + const isNativeStaking = + isStakingYieldType(yieldItem.mechanics.type) && + yieldItem.mechanics.requiresValidatorSelection + + if (yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + + if ( + yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || + (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) + ) { + return { + name: FIGMENT_VALIDATOR_NAME, + logoURI: FIGMENT_VALIDATOR_LOGO, + title: translate('yieldXYZ.nativeStaking'), + } + } + + if (isNativeStaking) { + return { + name: yieldItem.metadata.name, + logoURI: yieldItem.metadata.logoURI, + title: translate('yieldXYZ.nativeStaking'), + } + } + + const provider = providers?.[yieldItem.providerId] + return { name: provider?.name, logoURI: provider?.logoURI } + }, + [translate, providers], + ) +} diff --git a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts index 261c676c605..c6bd9965e9e 100644 --- a/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts +++ b/src/react-queries/queries/yieldxyz/useAllYieldBalances.ts @@ -285,10 +285,15 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => const augmentedBalances = augmentYieldBalances(item.balances, chainId) + const defaultValidator = DEFAULT_VALIDATOR_BY_YIELD_ID[item.yieldId] + const filteredBalances = augmentedBalances.filter( + balance => !defaultValidator || balance.validator?.address === defaultValidator, + ) + let highestAmountUsd = bnOrZero(0) let highestAmountUsdValidator: string | undefined - for (const balance of augmentedBalances) { + for (const balance of filteredBalances) { const usd = bnOrZero(balance.amountUsd) if (balance.validator?.address && usd.gt(highestAmountUsd)) { highestAmountUsd = usd @@ -300,12 +305,7 @@ export const useAllYieldBalances = (options: UseAllYieldBalancesOptions = {}) => balanceMap[item.yieldId] = [] } - for (const balance of augmentedBalances) { - const defaultValidator = DEFAULT_VALIDATOR_BY_YIELD_ID[item.yieldId] - if (defaultValidator && balance.validator?.address !== defaultValidator) { - continue - } - + for (const balance of filteredBalances) { const network = item.yieldId.split('-')[0] const lookupKey = `${balance.address.toLowerCase()}:${network}` let accountId = addressToAccountId[lookupKey] From 99c4280be0fc7c68ecbf7e82c0fbbb15b8949586 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:51:49 -1000 Subject: [PATCH 12/17] fix: use inputToken symbol for deposit-related displays For liquid staking yields like Lido, the API returns token=stETH (receipt token) and inputTokens[0]=ETH (deposit token). The UI was incorrectly showing stETH in deposit contexts. Now uses inputTokens[0]?.symbol with fallback to token.symbol for deposit displays while keeping token.symbol for position/balance displays. Co-Authored-By: Claude Opus 4.5 --- src/pages/Yields/YieldDetail.tsx | 4 ++-- .../Yields/components/YieldAvailableToDeposit.tsx | 9 +++++---- src/pages/Yields/components/YieldHero.tsx | 2 +- src/pages/Yields/components/YieldsList.tsx | 12 ++++++++---- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 0c4998b4400..c3f51737946 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -297,7 +297,7 @@ export const YieldDetail = memo(() => { )} @@ -321,7 +321,7 @@ export const YieldDetail = memo(() => { )} diff --git a/src/pages/Yields/components/YieldAvailableToDeposit.tsx b/src/pages/Yields/components/YieldAvailableToDeposit.tsx index 4acdedbb0ef..dfebfc27bca 100644 --- a/src/pages/Yields/components/YieldAvailableToDeposit.tsx +++ b/src/pages/Yields/components/YieldAvailableToDeposit.tsx @@ -48,6 +48,7 @@ export const YieldAvailableToDeposit = memo( ) const inputToken = yieldItem.inputTokens[0] + const inputTokenSymbol = inputToken?.symbol ?? yieldItem.token.symbol const inputTokenAssetId = inputToken?.assetId ?? '' const inputTokenPrecision = inputToken?.decimals @@ -81,7 +82,7 @@ export const YieldAvailableToDeposit = memo( if (!inputTokenPrecision || !hasWallet) return null const tooltipLabel = translate('yieldXYZ.availableToDepositTooltip', { - symbol: yieldItem.token.symbol, + symbol: inputTokenSymbol, }) if (!hasAvailableBalance) { @@ -112,7 +113,7 @@ export const YieldAvailableToDeposit = memo( - + @@ -125,7 +126,7 @@ export const YieldAvailableToDeposit = memo( width='full' fontWeight='bold' > - {translate('yieldXYZ.getAsset', { symbol: yieldItem.token.symbol })} + {translate('yieldXYZ.getAsset', { symbol: inputTokenSymbol })} @@ -162,7 +163,7 @@ export const YieldAvailableToDeposit = memo( diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 2b488e12fc9..a65ed7e5b87 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -163,7 +163,7 @@ export const YieldHero = memo( )} - {yieldItem.token.symbol} + {yieldItem.inputTokens[0]?.symbol ?? yieldItem.token.symbol} {yieldItem.chainId && ( diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 6ca88253e6c..049b1126f12 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -428,9 +428,13 @@ export const YieldsList = memo(() => { .minus(b.yield.statistics?.tvlUsd ?? 0) .toNumber() case 'name-asc': - return a.yield.token.symbol.localeCompare(b.yield.token.symbol) + return (a.yield.inputTokens[0]?.symbol ?? a.yield.token.symbol).localeCompare( + b.yield.inputTokens[0]?.symbol ?? b.yield.token.symbol, + ) case 'name-desc': - return b.yield.token.symbol.localeCompare(a.yield.token.symbol) + return (b.yield.inputTokens[0]?.symbol ?? b.yield.token.symbol).localeCompare( + a.yield.inputTokens[0]?.symbol ?? 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) @@ -513,7 +517,7 @@ export const YieldsList = memo(() => { { header: translate('yieldXYZ.yield'), id: 'pool', - accessorFn: row => row.token.symbol, + accessorFn: row => row.inputTokens[0]?.symbol ?? row.token.symbol, enableSorting: true, sortingFn: 'alphanumeric', cell: ({ row }) => { @@ -527,7 +531,7 @@ export const YieldsList = memo(() => { )} - {row.original.token.symbol} + {row.original.inputTokens[0]?.symbol ?? row.original.token.symbol} {row.original.chainId && ( From 808a613fa9ea46833c5ae8e7b6c859a80a7f2f6c Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:05:12 -1000 Subject: [PATCH 13/17] fix: add Claim button for withdrawable balances with CLAIM_UNSTAKED action Withdrawable balances (e.g. Lido completed unstakes) include a CLAIM_UNSTAKED pending action but the UI only checked claimable balances for claim actions. This extends claim detection to also check withdrawable balances, adds a Claim button to the withdrawable section, fixes the "Claim_unstaked" untranslated button text, and uses tx.type for accurate step title resolution. Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 3 +- src/lib/yieldxyz/utils.ts | 8 ++++ src/pages/Yields/components/YieldForm.tsx | 25 +++++++++-- .../Yields/components/YieldPositionCard.tsx | 44 ++++++++++++++++--- .../Yields/hooks/useYieldTransactionFlow.ts | 9 +++- 5 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index f49cb5cb90b..09d19a4adbb 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2830,7 +2830,8 @@ "potentialEarningsAmount": "%{amount}/yr at %{apy}% APY", "depositNow": "Deposit Now", "strategyInfo": "Strategy Info", - "overview": "Overview" + "overview": "Overview", + "readyToClaim": "Ready to claim" }, "earn": { "enterFrom": "Enter from", diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index 00c25690152..e2c6b0c1af6 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -61,6 +61,7 @@ const TX_TYPE_TO_LABELS: Record = { SWAP: { staking: 'Swap', vault: 'Swap' }, CLAIM: { staking: 'Claim', vault: 'Claim' }, CLAIM_REWARDS: { staking: 'Claim', vault: 'Claim' }, + CLAIM_UNSTAKED: { staking: 'Claim', vault: 'Claim' }, TRANSFER: { staking: 'Transfer', vault: 'Transfer' }, } @@ -113,9 +114,16 @@ export const formatYieldTxTitle = ( title: string, assetSymbol: string, yieldType?: YieldType, + txType?: string, ): string => { const labelKey: TerminologyKey = yieldType && isStakingYieldType(yieldType) ? 'staking' : 'vault' + if (txType) { + const normalizedType = txType.toUpperCase().replace(/[_-]/g, '_') + const typeLabels = TX_TYPE_TO_LABELS[normalizedType] + if (typeLabels) return `${typeLabels[labelKey]} ${assetSymbol}` + } + const normalized = title.replace(/ transaction$/i, '').toLowerCase() const match = TX_TITLE_PATTERNS.find(p => p.pattern.test(normalized)) if (match) return `${match[labelKey]} ${assetSymbol}` diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 41e5c61fab6..95ac3a27e51 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -116,15 +116,29 @@ export const YieldForm = memo( const inputTokenAssetId = inputToken?.assetId const claimableBalance = useMemo(() => balances?.byType[YieldBalanceType.Claimable], [balances]) - const claimableToken = claimableBalance?.token - const claimableAmount = claimableBalance?.aggregatedAmount ?? '0' + const withdrawableBalance = useMemo( + () => balances?.byType[YieldBalanceType.Withdrawable], + [balances], + ) + const claimableToken = claimableBalance?.token ?? withdrawableBalance?.token + const claimableAmount = + claimableBalance?.aggregatedAmount ?? withdrawableBalance?.aggregatedAmount ?? '0' const isClaimAction = action === 'claim' - const claimAction = useMemo( + const claimableClaimAction = useMemo( () => claimableBalance?.pendingActions?.find(a => a.type.toUpperCase().includes('CLAIM')), [claimableBalance], ) + const claimAction = useMemo( + () => + claimableClaimAction ?? + withdrawableBalance?.pendingActions?.find(a => a.type.toUpperCase().includes('CLAIM')), + [claimableClaimAction, withdrawableBalance], + ) + + const isWithdrawableClaim = !claimableClaimAction && Boolean(claimAction) + const accountIdFilter = useMemo( () => ({ assetId: inputTokenAssetId ?? '' }), [inputTokenAssetId], @@ -600,7 +614,9 @@ export const YieldForm = memo( - {translate('yieldXYZ.claimableRewards')} + {translate( + isWithdrawableClaim ? 'yieldXYZ.readyToClaim' : 'yieldXYZ.claimableRewards', + )} ) @@ -654,6 +670,7 @@ export const YieldForm = memo( }, [ isLoading, isClaimAction, + isWithdrawableClaim, claimableToken, claimableAmount, translate, diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index eb40e665ee9..d928bc3be44 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -109,13 +109,21 @@ export const YieldPositionCard = memo( () => claimableBalance?.pendingActions?.find(action => action.type.toUpperCase().includes('CLAIM'), + ) ?? + withdrawableBalance?.pendingActions?.find(action => + action.type.toUpperCase().includes('CLAIM'), ), - [claimableBalance], + [claimableBalance, withdrawableBalance], ) const canClaim = useMemo( - () => Boolean(claimAction && bnOrZero(claimableBalance?.aggregatedAmount).gt(0)), - [claimAction, claimableBalance?.aggregatedAmount], + () => + Boolean( + claimAction && + (bnOrZero(claimableBalance?.aggregatedAmount).gt(0) || + bnOrZero(withdrawableBalance?.aggregatedAmount).gt(0)), + ), + [claimAction, claimableBalance?.aggregatedAmount, withdrawableBalance?.aggregatedAmount], ) const formatBalance = useCallback((balance: AggregatedBalance | undefined) => { @@ -276,6 +284,14 @@ export const YieldPositionCard = memo( }) }, [hasExiting, exitingEntries, translate, pendingStatusKeys.exit]) + const withdrawableClaimAction = useMemo( + () => + withdrawableBalance?.pendingActions?.find(action => + action.type.toUpperCase().includes('CLAIM'), + ), + [withdrawableBalance], + ) + const withdrawableSection = useMemo(() => { if (!hasWithdrawable) return null return ( @@ -289,13 +305,27 @@ export const YieldPositionCard = memo( {formatBalance(withdrawableBalance)} - - {translate('yieldXYZ.ready')} - + + + {translate('yieldXYZ.ready')} + + {withdrawableClaimAction && ( + + )} + ) - }, [hasWithdrawable, translate, formatBalance, withdrawableBalance]) + }, [ + hasWithdrawable, + translate, + formatBalance, + withdrawableBalance, + withdrawableClaimAction, + handleClaimClick, + ]) const claimableSection = useMemo(() => { if (!hasClaimable) return null diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 5f75bce2d6c..b523aca1d06 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -355,6 +355,7 @@ export const useYieldTransactionFlow = ({ tx.title || `Transaction ${i + 1}`, assetSymbol, yieldItem?.mechanics.type, + tx.type, ), originalTitle: tx.title || '', type: tx.type, @@ -447,7 +448,12 @@ export const useYieldTransactionFlow = ({ accountId, message: typeMessagesMap[actionType] ?? - formatYieldTxTitle(tx.title || 'Transaction', assetSymbol, yieldItem.mechanics.type), + formatYieldTxTitle( + tx.title || 'Transaction', + assetSymbol, + yieldItem.mechanics.type, + tx.type, + ), amountCryptoPrecision: amount, contractName: yieldItem.metadata.name, chainName: yieldItem.network, @@ -771,6 +777,7 @@ export const useYieldTransactionFlow = ({ tx.title || `Transaction ${i + 1}`, assetSymbol, yieldItem?.mechanics.type, + tx.type, ), originalTitle: tx.title || '', type: tx.type, From c0661573391f39266ca7a5e2d5c411fbc1f96cc8 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:03:23 -1000 Subject: [PATCH 14/17] fix: show action-relevant info in exit/withdraw modal Hide APY and estimated yearly earnings when withdrawing since they're irrelevant for exit actions. Filter yield explainers by action relevance so exit modal only shows withdraw/unbonding info, not deposit info. Co-Authored-By: Claude Opus 4.5 --- .../components/Earn/EarnConfirm.tsx | 2 +- .../Yields/components/YieldEnterModal.tsx | 1 + .../Yields/components/YieldExplainers.tsx | 112 +++++++++++------- src/pages/Yields/components/YieldForm.tsx | 41 ++++--- 4 files changed, 93 insertions(+), 63 deletions(-) diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index e3328675b08..8af2c6e1f60 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -367,7 +367,7 @@ export const EarnConfirm = memo(() => { {selectedYield && ( - + )} diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 11b492c8acd..c52d39b7419 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -665,6 +665,7 @@ export const YieldEnterModal = memo( {stepsToShow.length > 0 && } diff --git a/src/pages/Yields/components/YieldExplainers.tsx b/src/pages/Yields/components/YieldExplainers.tsx index d1657b526bf..0777dfae5a5 100644 --- a/src/pages/Yields/components/YieldExplainers.tsx +++ b/src/pages/Yields/components/YieldExplainers.tsx @@ -15,6 +15,7 @@ const infoIcon = type ExplainerItem = { icon: ReactNode textKey: string + relevance: 'enter' | 'exit' | 'both' } const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => { @@ -29,31 +30,40 @@ const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] = textKey: outputTokenSymbol ? 'earn.explainers.liquidStakingReceive' : 'earn.explainers.liquidStakingTrade', + relevance: 'enter' as const, + }, + { icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule', relevance: 'enter' as const }, + { + icon: infoIcon, + textKey: 'earn.explainers.liquidStakingWithdraw', + relevance: 'both' as const, }, - { 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' }, + { icon: giftIcon, textKey: 'earn.explainers.rewardsSchedule', relevance: 'enter' as const }, + { icon: infoIcon, textKey: 'earn.explainers.stakingUnbonding', relevance: 'both' as const }, ] case 'restaking': return [ - { icon: giftIcon, textKey: 'earn.explainers.restakingYield' }, - { icon: infoIcon, textKey: 'earn.explainers.restakingWithdraw' }, + { icon: giftIcon, textKey: 'earn.explainers.restakingYield', relevance: 'enter' as const }, + { + icon: infoIcon, + textKey: 'earn.explainers.restakingWithdraw', + relevance: 'both' as const, + }, ] case 'vault': return [ - { icon: giftIcon, textKey: 'earn.explainers.vaultYield' }, - { icon: infoIcon, textKey: 'earn.explainers.vaultWithdraw' }, + { icon: giftIcon, textKey: 'earn.explainers.vaultYield', relevance: 'enter' as const }, + { icon: infoIcon, textKey: 'earn.explainers.vaultWithdraw', relevance: 'both' as const }, ] case 'lending': return [ - { icon: giftIcon, textKey: 'earn.explainers.lendingYield' }, - { icon: infoIcon, textKey: 'earn.explainers.lendingWithdraw' }, + { icon: giftIcon, textKey: 'earn.explainers.lendingYield', relevance: 'enter' as const }, + { icon: infoIcon, textKey: 'earn.explainers.lendingWithdraw', relevance: 'both' as const }, ] default: return [] @@ -63,48 +73,58 @@ const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] = type YieldExplainersProps = { selectedYield: AugmentedYieldDto sellAssetSymbol?: string + action: 'enter' | 'exit' | 'claim' } -export const YieldExplainers = memo(({ selectedYield, sellAssetSymbol }: YieldExplainersProps) => { - const translate = useTranslate() +export const YieldExplainers = memo( + ({ selectedYield, sellAssetSymbol, action }: YieldExplainersProps) => { + const translate = useTranslate() - const explainers = useMemo(() => getYieldExplainers(selectedYield), [selectedYield]) + const actionRelevance = action === 'enter' ? 'enter' : 'exit' + const explainers = useMemo( + () => + getYieldExplainers(selectedYield).filter( + e => e.relevance === actionRelevance || e.relevance === 'both', + ), + [selectedYield, actionRelevance], + ) - const rewardSchedule = selectedYield.mechanics.rewardSchedule - const outputSymbol = selectedYield.outputToken?.symbol + 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 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 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]) + 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 + if (translatedExplainers.length === 0) return null - return ( - - {translatedExplainers.map((explainer, index) => ( - - {explainer.icon} - - {explainer.text} - - - ))} - - ) -}) + return ( + + {translatedExplainers.map((explainer, index) => ( + + {explainer.icon} + + {explainer.text} + + + ))} + + ) + }, +) diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 95ac3a27e51..b40c49021a3 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -10,6 +10,7 @@ import { Icon, Skeleton, Text, + VStack, } from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' @@ -506,23 +507,27 @@ export const YieldForm = memo( const statsContent = useMemo( () => ( - - - - {translate('yieldXYZ.currentApy')} - - - {apyDisplay} - - - {hasAmount && ( - + {action === 'enter' && ( + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + )} + {action === 'enter' && hasAmount && ( + {translate('yieldXYZ.estYearlyEarnings')} @@ -537,7 +542,7 @@ export const YieldForm = memo( )} {isStaking && maybeSelectedValidatorMetadata && ( - + {translate('yieldXYZ.validator')} @@ -554,7 +559,7 @@ export const YieldForm = memo( )} {(!isStaking || !maybeSelectedValidatorMetadata) && maybeProviderMetadata && ( - + {translate('yieldXYZ.provider')} @@ -571,7 +576,7 @@ export const YieldForm = memo( )} {minDeposit && bnOrZero(minDeposit).gt(0) && action === 'enter' && ( - + {translate(getYieldMinAmountKey(yieldItem.mechanics.type))} @@ -584,7 +589,7 @@ export const YieldForm = memo( )} - + ), [ translate, @@ -748,7 +753,11 @@ export const YieldForm = memo( )} {!isClaimAction && statsContent} {!isClaimAction && ( - + )} {stepsToShow.length > 0 && } From 45836a2812ff942bc1abac71bb84080477e8e219 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:23:47 -1000 Subject: [PATCH 15/17] fix: disable unstake button when no active balance to unstake Co-Authored-By: Claude Opus 4.5 --- src/assets/translations/en/main.json | 1 + .../components/Earn/EarnConfirm.tsx | 6 ++- .../Yields/components/YieldPositionCard.tsx | 45 +++++++++++-------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 09d19a4adbb..65904b2069b 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2802,6 +2802,7 @@ "depositsDisabledDescription": "Deposits are temporarily unavailable for this yield opportunity.", "withdrawalsDisabled": "Withdrawals Disabled", "withdrawalsDisabledDescription": "Withdrawals are temporarily unavailable for this yield opportunity.", + "noActiveBalanceToExit": "No active balance available to unstake.", "noAvailableYields": "No yield opportunities available for your assets", "connectWalletAvailable": "Connect a wallet to see yields available for your assets", "aboutProvider": "About %{provider}", diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index 8af2c6e1f60..93ad42cd741 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -367,7 +367,11 @@ export const EarnConfirm = memo(() => { {selectedYield && ( - + )} diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index d928bc3be44..f162cef8eaa 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -12,6 +12,7 @@ import { HStack, Skeleton, Text, + Tooltip, VStack, } from '@chakra-ui/react' import { fromAccountId } from '@shapeshiftoss/caip' @@ -105,6 +106,17 @@ export const YieldPositionCard = memo( const withdrawableBalance = balancesByType?.[YieldBalanceType.Withdrawable] const claimableBalance = balancesByType?.[YieldBalanceType.Claimable] + const hasActiveBalance = Boolean( + activeBalance && bnOrZero(activeBalance.aggregatedAmount).gt(0), + ) + + const isExitDisabled = !yieldItem.status.exit || !hasActiveBalance + + const exitDisabledTitle = useMemo(() => { + if (!yieldItem.status.exit) return translate('yieldXYZ.withdrawalsDisabledDescription') + if (!hasActiveBalance) return translate('yieldXYZ.noActiveBalanceToExit') + }, [hasActiveBalance, translate, yieldItem.status.exit]) + const claimAction = useMemo( () => claimableBalance?.pendingActions?.find(action => @@ -505,24 +517,21 @@ export const YieldPositionCard = memo( {enterLabel} {hasAnyPosition && ( - + + + )} From 281082de6bff4bc596ea56bf54e6a1687ce2d233 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:51:48 -1000 Subject: [PATCH 16/17] fix: use bnOrZero to select effective claim balance and add input token fallbacks claimableToken/claimableAmount used ?? which didn't fall through when claimableBalance existed with aggregatedAmount '0'. Now uses bnOrZero check and respects isWithdrawableClaim. Also adds yieldItem.token fallbacks for assetId and decimals in YieldAvailableToDeposit. Co-Authored-By: Claude Opus 4.5 --- .../Yields/components/YieldAvailableToDeposit.tsx | 4 ++-- src/pages/Yields/components/YieldForm.tsx | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/Yields/components/YieldAvailableToDeposit.tsx b/src/pages/Yields/components/YieldAvailableToDeposit.tsx index dfebfc27bca..5901f571f11 100644 --- a/src/pages/Yields/components/YieldAvailableToDeposit.tsx +++ b/src/pages/Yields/components/YieldAvailableToDeposit.tsx @@ -49,8 +49,8 @@ export const YieldAvailableToDeposit = memo( const inputToken = yieldItem.inputTokens[0] const inputTokenSymbol = inputToken?.symbol ?? yieldItem.token.symbol - const inputTokenAssetId = inputToken?.assetId ?? '' - const inputTokenPrecision = inputToken?.decimals + const inputTokenAssetId = inputToken?.assetId ?? yieldItem.token.assetId ?? '' + const inputTokenPrecision = inputToken?.decimals ?? yieldItem.token.decimals const availableBalanceBaseUnit = useAppSelector(state => selectPortfolioCryptoBalanceBaseUnitByFilter(state, { assetId: inputTokenAssetId }), diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 95ac3a27e51..e257f1c0fcb 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -120,9 +120,6 @@ export const YieldForm = memo( () => balances?.byType[YieldBalanceType.Withdrawable], [balances], ) - const claimableToken = claimableBalance?.token ?? withdrawableBalance?.token - const claimableAmount = - claimableBalance?.aggregatedAmount ?? withdrawableBalance?.aggregatedAmount ?? '0' const isClaimAction = action === 'claim' const claimableClaimAction = useMemo( @@ -139,6 +136,18 @@ export const YieldForm = memo( const isWithdrawableClaim = !claimableClaimAction && Boolean(claimAction) + const effectiveClaimBalance = useMemo(() => { + if (isWithdrawableClaim) return withdrawableBalance + + const hasClaimableAmount = !bnOrZero(claimableBalance?.aggregatedAmount).isZero() + if (hasClaimableAmount) return claimableBalance + + return withdrawableBalance + }, [isWithdrawableClaim, claimableBalance, withdrawableBalance]) + + const claimableToken = effectiveClaimBalance?.token + const claimableAmount = effectiveClaimBalance?.aggregatedAmount ?? '0' + const accountIdFilter = useMemo( () => ({ assetId: inputTokenAssetId ?? '' }), [inputTokenAssetId], From 8527a2ec49dd0db2b1c018b182be96a51f3c65b5 Mon Sep 17 00:00:00 2001 From: gomes <17035424+gomesalexandre@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:54:19 +0100 Subject: [PATCH 17/17] chore: trigger CI